goatedit-mcp 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -13
- package/bundle/goatedit-mcp.js +629 -179
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,12 @@ out other web pages open in the same browser, so on top of that:
|
|
|
28
28
|
localhost origins (add more with `GOATEDIT_ALLOWED_ORIGINS`);
|
|
29
29
|
- a `/controller` socket (scripts such as `mcp-test.mjs`) must send
|
|
30
30
|
`Authorization: Bearer <token>`. The token is random per run and written to
|
|
31
|
-
`~/.goatedit/mcp-controller-token` (mode 0600).
|
|
31
|
+
`~/.goatedit/mcp-controller-token` (mode 0600). A reply goes back only to
|
|
32
|
+
the controller that asked.
|
|
33
|
+
|
|
34
|
+
Only one process can hold the port. A second session (another Claude window)
|
|
35
|
+
does not fail: it connects to the one holding the port as a controller and
|
|
36
|
+
works through it, and takes the port over if that one exits.
|
|
32
37
|
|
|
33
38
|
---
|
|
34
39
|
|
|
@@ -36,7 +41,7 @@ out other web pages open in the same browser, so on top of that:
|
|
|
36
41
|
|
|
37
42
|
### From npm
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
Nothing to clone or build — the package is one bundled file
|
|
40
45
|
with no dependencies:
|
|
41
46
|
|
|
42
47
|
```bash
|
|
@@ -97,24 +102,37 @@ Safari do not. Either use Chrome, or run the editor over plain http on localhost
|
|
|
97
102
|
|
|
98
103
|
| Variable | Default | Meaning |
|
|
99
104
|
|---|---|---|
|
|
100
|
-
| `GOATEDIT_ASSETS_DIR` | `~/Movies` | The one folder this server may list and serve |
|
|
105
|
+
| `GOATEDIT_ASSETS_DIR` | `~/Movies` | The one folder (with its subfolders) this server may list and serve |
|
|
106
|
+
| `GOATEDIT_FRAMES_DIR` | `~/.goatedit/frames` | Where `saveToDisk` writes rendered frames |
|
|
101
107
|
| `GOATEDIT_ALLOWED_ORIGINS` | — | Extra editor origins, comma-separated (production and localhost are always allowed) |
|
|
102
108
|
| `GOATEDIT_CONTROLLER_TOKEN` | random per run | Pin the `/controller` token instead of generating one |
|
|
103
109
|
|
|
104
|
-
`list_local_assets` reads that folder
|
|
105
|
-
|
|
110
|
+
`list_local_assets` reads that folder and its subfolders (hidden ones skipped,
|
|
111
|
+
up to 500 files); `/media/<path>` serves from it with byte ranges, so video
|
|
112
|
+
seeks, and paths that resolve outside it are a 404. Generated voiceovers are
|
|
113
|
+
written there too.
|
|
106
114
|
|
|
107
115
|
---
|
|
108
116
|
|
|
109
117
|
## Tools
|
|
110
118
|
|
|
111
|
-
|
|
119
|
+
These tools are local to this server, because they touch this machine or its
|
|
120
|
+
socket table:
|
|
112
121
|
|
|
113
122
|
| Tool | Description |
|
|
114
123
|
|---|---|
|
|
115
|
-
| `list_connected_projects` | Every GoatEdit tab attached right now, and which is the default target |
|
|
124
|
+
| `list_connected_projects` | Every GoatEdit tab attached right now, by name, and which is the default target |
|
|
125
|
+
| `use_project` | Pick the project later calls act on, when several tabs are open |
|
|
116
126
|
| `list_local_assets` | Media in `GOATEDIT_ASSETS_DIR`, with URLs the browser can load |
|
|
117
|
-
| `generate_voiceover` | Synthesise narration with macOS `say
|
|
127
|
+
| `generate_voiceover` | Synthesise narration with macOS `say`, measure it, and add it to the bin |
|
|
128
|
+
| `run_tools` | Run a list of tool calls in order in one round trip |
|
|
129
|
+
|
|
130
|
+
`render_preview_frame` and `preview_media` take `saveToDisk`. Here the server
|
|
131
|
+
writes the frame to `GOATEDIT_FRAMES_DIR` itself and returns the path, so no
|
|
132
|
+
separate bridge is needed.
|
|
133
|
+
|
|
134
|
+
Results are compact JSON, and pictures come back as MCP image blocks, not as
|
|
135
|
+
base64 text.
|
|
118
136
|
|
|
119
137
|
Every other tool comes from `api/_lib/mcp-tools.ts` — the same catalogue the
|
|
120
138
|
hosted server at ai.goatedit.com/mcp serves. It is imported, not copied, so the
|
|
@@ -126,14 +144,13 @@ The one shared tool that is dropped is `get_session_status`, which asks the
|
|
|
126
144
|
hosted relay about its Realtime session. This server has no session — it has a
|
|
127
145
|
socket table, which `list_connected_projects` reports.
|
|
128
146
|
|
|
129
|
-
Every tool takes an optional `projectId`. One stdio server can hold several tabs
|
|
130
|
-
at once; without it, edits go to the most recently active one.
|
|
131
|
-
|
|
132
147
|
Clip ids change after every split, trim and delete — call `get_timeline` before
|
|
133
148
|
each edit rather than reusing ids from an earlier response.
|
|
134
149
|
|
|
135
|
-
Multiple tabs can be attached at once. Tools
|
|
136
|
-
|
|
150
|
+
Multiple tabs can be attached at once. Tools act on the project chosen with
|
|
151
|
+
`use_project`, else the most recently active one. A `projectId` argument on any
|
|
152
|
+
call is still honoured, and one that is not connected is an error rather than
|
|
153
|
+
a silent fallback to another tab.
|
|
137
154
|
|
|
138
155
|
## Platform notes
|
|
139
156
|
|
package/bundle/goatedit-mcp.js
CHANGED
|
@@ -19148,6 +19148,7 @@ import fs from "fs";
|
|
|
19148
19148
|
import path from "path";
|
|
19149
19149
|
import { execFileSync } from "child_process";
|
|
19150
19150
|
import os from "os";
|
|
19151
|
+
import { fileURLToPath } from "url";
|
|
19151
19152
|
|
|
19152
19153
|
// ../api/_lib/mcp-tools.ts
|
|
19153
19154
|
var num = (description) => ({ type: "number", description });
|
|
@@ -19275,9 +19276,96 @@ var TOOLS = [
|
|
|
19275
19276
|
method: "lintTimeline"
|
|
19276
19277
|
},
|
|
19277
19278
|
{
|
|
19278
|
-
name: "
|
|
19279
|
-
description: "
|
|
19279
|
+
name: "get_edit_log",
|
|
19280
|
+
description: "What recent MCP edits did to this project, as recorded by the editor tab: when each write arrived, whether over the local or the remote connection, the method, how many clips it added, and every clip and nested sequence it removed. Use it when clips have gone missing and you need to know which command \u2014 and which connection \u2014 took them. Only MCP writes are recorded; hand edits and collaborators are not.",
|
|
19281
|
+
inputSchema: {
|
|
19282
|
+
type: "object",
|
|
19283
|
+
properties: {
|
|
19284
|
+
limit: { type: "number", description: "Most recent entries to return (default 50, max 500)." },
|
|
19285
|
+
removalsOnly: { type: "boolean", description: "Only entries that removed something." }
|
|
19286
|
+
}
|
|
19287
|
+
},
|
|
19288
|
+
method: "getEditLog"
|
|
19289
|
+
},
|
|
19290
|
+
{
|
|
19291
|
+
name: "clean_unused_sequences",
|
|
19292
|
+
description: "Find nested sequences that no clip uses any more \u2014 left behind when a nest clip is removed or a shot is rebuilt \u2014 and optionally delete them with their bin items. Without apply it only lists them. Top-level sequences, and nests reachable from one or open in a tab, are never touched.",
|
|
19293
|
+
inputSchema: {
|
|
19294
|
+
type: "object",
|
|
19295
|
+
properties: {
|
|
19296
|
+
apply: { type: "boolean", description: "Delete them. Default false: list only." }
|
|
19297
|
+
}
|
|
19298
|
+
},
|
|
19299
|
+
method: "cleanUnusedSequences"
|
|
19300
|
+
},
|
|
19301
|
+
{
|
|
19302
|
+
name: "list_sequences",
|
|
19303
|
+
description: "Every sequence in the project: id, name, whether it is the one open for editing, open in a tab, a nest (made by nest_clips) or top-level, its length, how many clips it holds, and the clips that play it. Every other tool edits the open sequence only \u2014 use this to find the id to pass to open_sequence.",
|
|
19280
19304
|
inputSchema: { type: "object", properties: {} },
|
|
19305
|
+
method: "listSequences"
|
|
19306
|
+
},
|
|
19307
|
+
{
|
|
19308
|
+
name: "create_sequence",
|
|
19309
|
+
description: "Make a new, empty sequence with the project's frame size and rate. By default it opens, so the edits that follow build it; pass open: false to stay where you are and place it later with add_sequence_clip. Returns its id.",
|
|
19310
|
+
inputSchema: {
|
|
19311
|
+
type: "object",
|
|
19312
|
+
properties: {
|
|
19313
|
+
name: str("Name for the sequence."),
|
|
19314
|
+
open: { type: "boolean", description: "Open it for editing. Default true." }
|
|
19315
|
+
}
|
|
19316
|
+
},
|
|
19317
|
+
method: "createSequence"
|
|
19318
|
+
},
|
|
19319
|
+
{
|
|
19320
|
+
name: "open_sequence",
|
|
19321
|
+
description: "Open a sequence for editing \u2014 the same as clicking its tab. Every tool then reads and edits that sequence; clip and track ids from the one you left no longer apply, so call get_timeline next. Open the previous one again to go back out of a nest.",
|
|
19322
|
+
inputSchema: {
|
|
19323
|
+
type: "object",
|
|
19324
|
+
properties: { sequenceId: str("Sequence id from list_sequences.") },
|
|
19325
|
+
required: ["sequenceId"]
|
|
19326
|
+
},
|
|
19327
|
+
method: "openSequence"
|
|
19328
|
+
},
|
|
19329
|
+
{
|
|
19330
|
+
name: "nest_clips",
|
|
19331
|
+
description: "Move clips into a new nested sequence, replaced on the timeline by one clip that plays it (a precomp) \u2014 to animate, mask or transition a group as one layer. Linked sound comes along with its picture. The clips keep their ids and relative timing inside the nest; open_sequence with the returned sequenceId edits them.",
|
|
19332
|
+
inputSchema: {
|
|
19333
|
+
type: "object",
|
|
19334
|
+
properties: {
|
|
19335
|
+
clipIds: { type: "array", items: { type: "string" }, description: "Clips to nest, from get_timeline." },
|
|
19336
|
+
name: str("Name for the nested sequence.")
|
|
19337
|
+
},
|
|
19338
|
+
required: ["clipIds"]
|
|
19339
|
+
},
|
|
19340
|
+
method: "nestClips"
|
|
19341
|
+
},
|
|
19342
|
+
{
|
|
19343
|
+
name: "add_sequence_clip",
|
|
19344
|
+
description: "Place an existing sequence on the open timeline as a clip that plays it. A sequence that is, or contains, the open one is refused, since it would play itself.",
|
|
19345
|
+
inputSchema: {
|
|
19346
|
+
type: "object",
|
|
19347
|
+
properties: {
|
|
19348
|
+
sequenceId: str("Sequence id from list_sequences."),
|
|
19349
|
+
trackId: str('Video track id, or "__new_video__" (default) for a free track above what is on screen.'),
|
|
19350
|
+
startTime: num("Timeline seconds. Defaults to the playhead."),
|
|
19351
|
+
duration: num("Seconds. Defaults to the sequence's length.")
|
|
19352
|
+
},
|
|
19353
|
+
required: ["sequenceId"]
|
|
19354
|
+
},
|
|
19355
|
+
method: "addSequenceClip"
|
|
19356
|
+
},
|
|
19357
|
+
{
|
|
19358
|
+
name: "get_timeline",
|
|
19359
|
+
description: 'Get just the timeline \u2014 all tracks with their clips, ids, start times and durations. Cheaper than get_project_state; use it before any edit so you have current clip ids. Animation data (keyframes, motion tracking, masks, per-word caption timings) is reported as counts under *Summary keys rather than in full, so a clip showing keyframeSummary is animated even though the keyframes themselves are not listed. By default the view is lean: a field at its usual value is left out (no volume means 1, no isMuted means false, no opacity means 1), and effects are listed by id and type only. Pass detail: "full" when you need effect parameters.',
|
|
19360
|
+
inputSchema: {
|
|
19361
|
+
type: "object",
|
|
19362
|
+
properties: {
|
|
19363
|
+
detail: { type: "string", enum: ["lean", "full"], description: "lean (default) or full, which adds effect parameters and every field." },
|
|
19364
|
+
trackId: str("Only this track."),
|
|
19365
|
+
from: num("Only clips that end after this time, in seconds."),
|
|
19366
|
+
to: num("Only clips that start before this time, in seconds.")
|
|
19367
|
+
}
|
|
19368
|
+
},
|
|
19281
19369
|
method: "getTimeline"
|
|
19282
19370
|
},
|
|
19283
19371
|
{
|
|
@@ -19665,7 +19753,7 @@ var TOOLS = [
|
|
|
19665
19753
|
clipId: CLIP_ID,
|
|
19666
19754
|
type: {
|
|
19667
19755
|
type: "string",
|
|
19668
|
-
description: "Effect type, e.g. blur, invert, hue, saturation, brightness, contrast, exposure, grayscale, sepia, gradientMap, vignette, filmGrain, flicker, posterizeTime, lensDistortion, chromaticAberration, lensFlare, liquidGlass, opacityFx, colorCorrection, depthMap, gradientMap, glow, pixelDissolve, motionBlur, colorama, outline, echo, flutedGlass, lightRays, halftone, datamosh, roughenEdges, filmStock, halation, starGlow, thermal, colorBalance, duotone, levels, monochrome, channelMixer, dither, threshold, lensBlur, directionalBlur, spinBlur, zoomBlur, sharpen, cameraShake, bulge, twirl, ripple, waveWarp, polarCoords, turbulentDisplace, elasticGrid, gridSplit, cubify, glitch, vhs, ntsc, crtScreen, ledScreen, rgbSplit, jpegGlitch, ascii, macpaint, modulation, stripe, risograph, emboss, bevel, inkBleed, paperTexture, paperCutout, crossHatch, scatter, blobTracker. depthMap estimates how far away every pixel is and replaces the picture with that greyscale \u2014 near is white \u2014 running a real depth model in the browser; the first frame takes a few seconds while the model downloads, and the clip renders untouched until it lands. Its detail param (0..1, default 0.25) is what it costs: the low end runs the model at 182px and the high at 518, and attention is quadratic in patches, so turn it up only for a shot with fine geometry. A still image is estimated once for the whole clip; video is estimated fifteen times a second. gradientMap re-colours a frame by its own brightness through a hue ramp. Stacking them in that order, depthMap then gradientMap, is the false-colour depth-pass opener; keyframing either mix from 1 to 0 walks a shot back into its own colour. transform is Premiere's Transform effect: anchorX/anchorY/posX/posY as fractions of the layer (0.5 = centre), scale/scaleWidth/scaleHeight in %, skew, skewAxis, rotation, opacity in %, and shutter (degrees; 180 gives motion blur to the effect's own keyframed motion). layer3d makes a media or nested clip a 3D layer: rotateX (tilt, positive tips the top away), rotateY (swivel), posZ (1080p px away from camera), fov (camera field of view, degrees), aperture (depth-of-field blur, 0 = sharp) and focus (focus plane offset from the layer, px). model3d poses a 3D model clip (a .glb): rotateX/rotateY/rotateZ (degrees), spin (degrees per second about Y), offsetX/offsetY/offsetZ (in model radii), scale (%), fov, distance (% of the distance that frames the whole model), lighting (0 Studio, 1 Soft, 2 Dramatic, 3 Flat), lightAngle, exposure (stops), animation (index into the animations in the file, -1 holds still), animSpeed (%) and animOffset (seconds). showcase lays out a showcase clip (a looping 3D template of cards made from the Presets panel; its template and pictures are on the media): count (cards), rows, loop (seconds per seamless loop at speed 100), speed (%, 0 holds still, 200 plays twice as fast; keyframe it to ease the motion in or out), direction (0 forward, 1 reverse), spread and cardSize (%), tilt, swivel and roll (degrees, the whole scene), bend (%, cards curve to follow the shape), corner (% of the short side), depthFade (%), ratio (0 Auto, 1 1:1, 2 4:3, 3 3:4, 4 4:5, 5 16:9, 6 9:16), fov and zoom (%). The Product templates (productRing, productOrbit, productRibbon, productWave) put one product (a cut-out picture or a 3D model) in the middle with text rings round it: productX/productY (% of the frame), productRx/productRy/productRz (degrees), productTurns (whole spins per loop), productFloat (%) with productFloatCycles, productSway (degrees), productShadow (%), productLight (0 Studio, 1 Soft, 2 Dramatic, 3 Flat \u2014 3D models), productExposure, productLightAngle, bgMode (0 none, 1 solid, 2 gradient, 3 radial) with bgColor1/bgColor2.r/g/b, and ring2On (0/1). Each ring's settings are prefixed ring1 or ring2: Style (0 dots, 1 solid, 2 pixels, 3 outline, 4 dashes, 5 stencil), Font (0 Helvetica, 1 Arial Black, 2 Futura, 3 Avenir, 4 Georgia, 5 Times, 6 Courier, 7 Menlo, 8 Impact, 9 Verdana, 10 Didot, 11 Rounded), Weight (0 light .. 3 black), Upper (0/1), Separator (0 space, 1 \u2022, 2 \u2726, 3 \u2605, 4 \u2014, 5 /, 6 +, 7 \u273A, 8 \xD7, 9 \u25C6), Spacing (%), DotSize (%), Density (dots per letter height), Color.r/g/b, Opacity (%), Glow, Size and Radius (%), Y (%), Tilt and Roll (degrees), Turns (whole turns per loop, negative reverses), Repeat (0 fits), BackFade (%), Wave (%) with Waves, and a ribbon behind the letters: Band (0/1), BandColor.r/g/b, BandOpacity, BandHeight (%). The ring words are on the media. glow adds light around the bright parts of the picture: threshold (0..1, how bright a pixel must be to glow), softness, radius (1080p px), intensity (0..5), tint.r/tint.g/tint.b (0..1) with tintAmount (0 keeps the picture's own colours, 1 is all tint \u2014 neon), and glowOnly (1 shows only the light). On text and shapes the glow spills past the letters. pixelDissolve breaks the clip into square blocks that drop out along a stair-stepped front: progress (0 whole, 1 gone \u2014 keyframe it for an entrance or exit), blockSize (1080p px), sweep (0 random, 1 follows direction), direction (degrees, 0 left to right, 90 upward), clump, edge (width of a coloured fringe of blocks at the front, 0 for none), edgeColor.r/g/b and edgeColor2.r/g/b, fill (1 turns lost blocks into fillColor.r/g/b instead of holes) and seed. colorama is After Effects' Colorama: phaseFrom (0 intensity, 1 lightness, 2 hue, 3 saturation, 4 red, 5 green, 6 blue, 7 alpha), phaseShift (degrees), speed (degrees per second, cycles the palette by itself), cycles (repetitions), colorCount (2..6) with color1..color6 as .r/.g/.b (0..1), interpolate (0 hard bands), loop (0 maps dark to color1 and bright to the last, like a gradient map), smoothing (1080p px), reveal (0..1, hands back the original darkest first \u2014 keyframe 0 to 1 for the false-colour-to-real transition) and mix. outline strokes and haloes the subject: source (0 the AI subject matte, 1 the layer's own alpha \u2014 use 1 on text and shapes), width (1080p px), strokeColor.r/g/b, aura (halo size, px), auraColor.r/g/b, auraIntensity, pulse (Hz), bgDim and bgDesat (0..1, darken or grey everything but the subject). echo leaves trails from the clip's previous frames: decay (0..0.99 per 1/60 s), blend (0 screen, 1 lighten, 2 add, 3 behind), intensity, hueShift (degrees per frame, rainbow trails), zoom (per-frame scale, >1 is a tunnel) and rotate (degrees per frame). flutedGlass: ribs, angle (0 vertical), refraction, blur, shading, chroma, wave and offset (in ribs; keyframe it to slide the glass). lightRays: center.x/center.y (0..1, y up), length, threshold, intensity, tint.r/g/b, tintAmount, raysOnly. halftone: mode (0 colour dots on a dark ground, 1 ink on paper, 2 CMYK print), dotSize (px), angle, contrast, softness, ink.r/g/b, paper.r/g/b, center.x/center.y, radius (frame heights; animate from 0 for a dot burst), feather, mix. datamosh drags the last frame along the new frame's motion like a broken codec: amount, blockSize, flow, refresh (how fast the real picture returns), bleed. roughenEdges is After Effects' Roughen Edges, for distressed and grunge titles on text, shapes or cut-outs: border (fray, 1080p px), roughness, sharpness, scale (px), complexity (1..6), choke (-1 spreads, 1 eats the layer away \u2014 keyframe 0 to 1 to erode it out), pits and texture (0..1, holes and mottled fill), edgeColor.r/g/b with edgeAmount (a burnt rim), slices, sliceOffset (px) and sliceAmount (bands knocked sideways), evolution (degrees), boilSpeed with boilRate (fps; 1 at 10 is a hand-drawn wiggle) and seed. filmStock emulates a film look: stock (0 warm portrait, 1 vivid negative, 2 golden 200, 3 cool consumer, 4 pastel pro, 5 classic slide, 6 two-strip, 7 tungsten 800, 8 bleach bypass, 9 cross process, 10 expired, 11 B&W classic, 12 B&W soft, 13 B&W high contrast, 14 infrared false colour, 15 sepia print), amount (0..1), fade (lifted blacks), grain. halation adds the warm film glow around highlights: threshold and knee (which parts glow), boost, radius (1080p px), local and global (tight core against wide spread), intensity, tint.r/g/b (red-orange by default), compensation (keeps highlights from brightening), backgroundGain (darkens the blacks a touch). starGlow draws star-filter streaks from bright points: threshold and knee, boost, intensity, streaks (1..8 rays), length (1080p px), angle (degrees), alternate (0..1 shortens every other ray), falloff, colorize (spectrum along each ray) with hueShift, tint.r/g/b with tintAmount, starsOnly. thermal reads brightness as heat on a camera palette: palette (0 iron, 1 rainbow, 2 white hot, 3 black hot, 4 arctic, 5 lava, 6 night vision), invert, low/high/gamma (levels on the reading), shift and repeat (cycle the palette), diffuse (1080p px of sensor blur), edges (0..1 outline strength), mix. colorBalance pushes shadows, midtones and highlights along cyan\u2013red, magenta\u2013green and yellow\u2013blue: shadowR/shadowG/shadowB, midR/midG/midB, highR/highG/highB (each -1..1), preserve (1 keeps brightness). duotone maps brightness onto two inks: dark.r/g/b and light.r/g/b (0..1), useMid with mid.r/g/b for a tritone, contrast, brightness, gamma, mix. levels: inBlack/inWhite (input range, 0..1), gamma (midtones, 1 unchanged), outBlack/outWhite (output range), channel (0 RGB, 1 red, 2 green, 3 blue), lumaOnly (1 adjusts brightness without shifting colour). monochrome is black and white through a colour filter: red/green/blue (channel weights, -1..2 \u2014 raise red to lighten skin and darken sky), normalize, contrast, tint.r/g/b with tintAmount (a toner: sepia, selenium, cyanotype). channelMixer builds each output channel from the three inputs: rr/rg/rb (red from red/green/blue), gr/gg/gb, br/bg/bb (each -2..2; identity is 1 on the diagonal), ro/go/bo (offsets). dither reduces the picture to a small palette with a dither pattern: palette (0 1-bit, 1 ink/paper using ink.r/g/b and paper.r/g/b, 2 handheld green, 3 CGA, 4 eight-colour, 5 sunset, 6 RGB levels, 7 gray levels), levels (for 6 and 7), method (0 Bayer 2x2, 1 Bayer 4x4, 2 Bayer 8x8, 3 noise, 4 lines), pixelSize (1080p px), spread (pattern strength), contrast, brightness, animate (noise boil), mix. threshold cuts the picture to two colours: level (0..1), softness, noise (grain in the cut), animate, mode (0 fill, 1 edges only, 2 fill with edges), edgeLevel and edgeWidth (px), invert, dark.r/g/b and light.r/g/b, mix. lensBlur is a depth-of-field blur with a real aperture: mode (0 whole frame, 1 outside an ellipse, 2 outside a band \u2014 tilt-shift, 3 behind the AI subject), strength (blur radius, 1080p px), center.x/center.y, radius and falloff (focus size and softness, frame heights), aspect, rotation, invert, samples (8..96), blades (0 round, 3..9 polygon iris), roundness, bladeRotation, anamorphic (vertical stretch of the bokeh), catadioptric (0..0.9, donut bokeh), highlights (how much bright points bloom into bokeh balls), showMask. directionalBlur smears along a line: strength (1080p px), angle (degrees, 0 horizontal), both (1 smears both ways, 0 trails one way), box (1 even smear, 0 fades), mix. spinBlur blurs around a point: center.x/center.y, angle (degrees of arc swept), samples (4..64), box. zoomBlur streaks out from a point: center.x/center.y, strength (0..1, fraction of the distance to the centre smeared), samples, both, box. sharpen is an unsharp mask: amount (0..5), radius (1080p px), threshold (0..0.2, leaves flat areas alone), ringing (0 no halos, 1 allows edge overshoot). cameraShake is a handheld wobble with motion blur: amount (overall scale), speed (wobbles per second), pan (1080p px), roll (degrees), zoom (%), blur (0..1 of a 1/24 s shutter), pauseEvery and pauseLength (seconds; 0 never pauses), overscan (1 zooms in so edges never show), seed. bulge magnifies or squeezes a round area: center.x/center.y, radius (frame heights), strength (-1 pinch .. 1 bulge). twirl spins the picture around a point, most at the centre: center.x/center.y, radius (frame heights), angle (degrees, keyframe it for a whirlpool transition). ripple spreads water rings from a point: center.x/center.y, amplitude (1080p px), wavelength (1080p px), speed (rings per second outward), phase (degrees), decay (how fast rings weaken with distance), dispersion (colour split), highlight (glint on crests). waveWarp bends the picture in waves: shape (0 sine, 1 square, 2 triangle, 3 sawtooth, 4 noise), direction (degrees the pixels move; 0 sideways), amplitude and wavelength (1080p px), speed (waves per second), phase, pinEdges (1 mirrors at the edges instead of leaving gaps). polarCoords wraps or unwraps the picture around a point: mode (0 rectangular to polar \u2014 the tiny-planet look, 1 polar to rectangular), center.x/center.y, radius (frame heights), rotation (degrees), invert (flips inside and outside \u2014 a tunnel), seamless (mirrors so the wrap has no seam), mix. turbulentDisplace warps the picture with fractal noise: amount (1080p px), size (px of the noise features), twist (swirls the flow), evolution (degrees), speed (per second), seed, pinEdges. elasticGrid stretches the picture on a wobbling grid: cols and rows, wobble (0..1.5 of a cell), speed, loop (seconds; >0 makes the motion loop seamlessly), center.x/center.y with pull (-1 pushes away, 1 pulls in) and radius, lines (0..1 grid line opacity) with lineWidth and lineColor.r/g/b, seed. gridSplit cuts the picture into tiles knocked out of place: cols and rows, amount (0..1 share of tiles moved), offset (1080p px), zoom (0..1 random scale), flip, gap (px) with gapColor.r/g/b and gapOpacity, rate (re-deals per second; 0 holds), seed. cubify rebuilds the picture from bevelled blocks: size (1080p px), stretch (block width ratio), rotation, bevel (0..0.5 of a block), shading, hard (sharp bevels), depth (extrude by brightness), dispersion (random scatter in blocks) with speed (re-scatters per second). glitch breaks the picture digitally: amount (0..1, how much of the frame is hit \u2014 keyframe it for bursts), rate (re-rolls per second), bandSize and offset (1080p px of the torn rows), rgbSplit (px), blocks (0..1 displaced blocks) with blockSize, invert (0..1 inverted blocks), noise, seed. vhs is tape playback: resolution (0 very soft .. 1 sharp), colorBlend and colorDelay (px, chroma smear and lag), sharpen (halo), saturation, colorShift (degrees of hue drift), wobble, tracking (rolling noisy band), crease, jump (vertical hops), distortion (head-switch wiggle at the bottom), sparkle (dropouts), flicker, grain, seed. ntsc encodes the picture as a composite TV signal and decodes it again: encoding (0..1.5, colour carrier strength \u2014 the source of dot crawl), carrier (1080p px per carrier cycle), fringes (rainbow colour on fine edges), bleed (1080p px the colour lags behind), ringing, smear (bright trails to the right), noise, saturation. crtScreen shows the picture on a tube TV: curvature, corner (screen rounding), bezel.r/g/b with bezelOpacity, scanlines (strength) with lines (count), mask (0 none, 1 aperture grille, 2 shadow mask, 3 slot mask) with maskStrength, maskSize (px) and maskSoftness, glow, gamma, brightness, vignette. ledScreen rebuilds the picture as an LED wall: size (1080p px per LED), gap (0..0.9), round (1 round LEDs, 0 square), softness, rgb (1 draws red, green and blue sub-bars), blur, brightness, bleed (light in the gaps), background.r/g/b, transparent (1 leaves the gaps see-through). rgbSplit offsets the colour channels: mode (0 linear, 1 radial \u2014 spins around center, 2 zoom \u2014 outward from center), red, green, blue (offsets; 1080p px in linear mode, relative strength otherwise), angle (degrees, linear mode), center.x/center.y, fringeAlpha (1 lets fringes spill past a title). jpegGlitch over-compresses and corrupts the picture: compression (0..1), blockSize (1080p px), chroma (0..1, colour held per block), ringing, corruption (0..1, rows decoded from the wrong place with wrong colour), rate (re-corrupts per second), seed. ascii redraws the picture as text: charSet (0 classic ramp, 1 blocks, 2 binary 0/1, 3 falling matrix code with speed), size (1080p px per character row), aspect (width/height of a cell), contrast, cutDarks and cutLights (0..0.5), invert, colorMode (0 single ink, 1 original colours), ink.r/g/b, background.r/g/b, mix. macpaint is 1-bit paint-program art: width (bitmap pixels across the frame), pattern (0 dots, 1 lines, 2 crosshatch fills), contrast, brightness, edgeLevel (0 no outline) and edgeWidth, ink.r/g/b, paper.r/g/b, mix. modulation draws the picture as lines bent by its brightness: spacing (1080p px between lines), sensitivity (how far brightness pushes a line), direction (degrees), lineWidth (0..1 of the spacing), signal (thicker lines where brighter), wave with waveScale, phase and speed (an oscillation along each line), red/green/blue (each channel draws its own lines; all off draws brightness), tint.r/g/b, background.r/g/b, mix. stripe renders the picture as a line screen: repetitions (stripes per frame height), minThickness and maxThickness (0..1), angle, softness, pattern (0 lines, 1 waves, 2 circles, 3 zigzag), speed (stripes per second), colorMode (1 inks with the picture colour), ink.r/g/b and paper.r/g/b, split (1 a screen per RGB channel) with redShift and blueShift (in stripes), rowShift (wavy rows), mix. risograph prints the picture in two or three spot inks: ink1.r/g/b, ink2.r/g/b, useInk3 with ink3.r/g/b, amount1, amount2 and amount3 (density of each), misregister (1080p px out of line), grain and grainSize (stencil texture), opacity, contrast, paper.r/g/b. emboss raises the picture into relief lit from one side: depth, size (1080p px of the relief), soften, angle and elevation (light direction, degrees), rotate (light turns, revolutions per minute), shine (metallic highlight), color (0 grey relief, 1 keeps the picture colours), tint.r/g/b. bevel lights a layer's shape as raised \u2014 best on text, shapes and cut-outs: style (0 inner bevel, 1 emboss, 2 pillow), size (1080p px), depth, angle (light direction, degrees), soften, highlight.r/g/b with highlightOpacity, shadow.r/g/b with shadowOpacity, gloss. inkBleed lets dark ink spread into the paper: spread (1080p px), intensity, decay, noiseSize (fibre size, px), direction (degrees) with directional (0..1, how much it runs one way), grain with grainSize, cycle (seconds; >0 animates the soak in and restarts). paperTexture prints the picture onto generated paper: kind (0 white, 1 kraft, 2 newsprint, 3 black stock, 4 watercolour), opacity, scale (texture size), brightness, contrast, distortion with distortionScale (a scanned sheet's waviness), folds (0..3 creases). paperCutout tears the picture into a collage of paper pieces: cols and rows, missing (0..1 share of pieces gone), jitter (position), rotation (degrees of random turn), overlap, roughness and edgeScale (torn edge), rim (white paper edge width) with rimColor.r/g/b, shadowSize (px), shadowAngle, shadowSoft, shadowOpacity, background.r/g/b, seed. crossHatch draws the picture in pen or pencil hatching, more layers of lines in darker tones: spacing (1080p px), width (0..1 of the spacing), angle, wobble (hand tremor), grain (pencil texture), contrast, brightness, colorMode (1 hatches in the picture's colours), ink.r/g/b, paper.r/g/b, mix. scatter rebuilds the picture from shapes sized by brightness: shape (0 circle, 1 square, 2 diamond, 3 triangle, 4 line, 5 cross, 6 ring), size (1080p px per cell), minSize and maxSize (0..2 of a cell), jitter (position), rotation (degrees of random turn), widthVariance, select (skip cells darker than this) with invert, colorMode (1 picture colours), ink.r/g/b, background.r/g/b, transparent (1 only the shapes), seed, mix. blobTracker draws computer-vision tracking boxes on the bright (or keyed) parts of the picture, each tagged with its coordinates: threshold, useKey with key.r/g/b (track a colour instead of brightness), cells (rows of big boxes; fewer finds bigger blobs), variety (0..1 share of extra small boxes), density (0..1 share of candidates boxed), style (0 box, 1 corner brackets), thickness (px), center (0 none, 1 dot, 2 crosshair), links (1 joins boxes with curved lines) with dashed, caption (1 coordinate tags) with captionSize (px), fillMode (0 tint, 1 invert, 2 mono \u2014 how the inside of a box is shown) with fill (0..1), dim (0..1 darkens everything outside the boxes), boxColor.r/g/b, rate (times a second each box re-aims; 0 holds), debug (shows the mask). motionBlur smears the clip's own keyframed movement (position, scale, rotation, parents, trackers): shutter (degrees, 180 is film), phase (-90 centres the smear, minus the shutter trails it behind) and samples (2..48). An unknown name comes back with the full list."
|
|
19756
|
+
description: "Effect type: blur, invert, hue, saturation, brightness, contrast, exposure, grayscale, sepia, gradientMap, vignette, filmGrain, flicker, posterizeTime, lensDistortion, chromaticAberration, lensFlare, liquidGlass, opacityFx, colorCorrection, depthMap, gradientMap, glow, pixelDissolve, motionBlur, dropShadow, reflection, colorama, outline, echo, flutedGlass, lightRays, halftone, datamosh, roughenEdges, filmStock, halation, starGlow, thermal, colorBalance, duotone, levels, monochrome, channelMixer, dither, threshold, lensBlur, directionalBlur, spinBlur, zoomBlur, sharpen, cameraShake, bulge, twirl, ripple, waveWarp, polarCoords, turbulentDisplace, elasticGrid, gridSplit, cubify, glitch, vhs, ntsc, crtScreen, ledScreen, rgbSplit, jpegGlitch, ascii, macpaint, modulation, stripe, risograph, emboss, bevel, inkBleed, paperTexture, paperCutout, crossHatch, scatter, blobTracker, edgeGlow, plus transform, layer3d, model3d and showcase. Call describe_effect with a type for its parameters, their ranges and what they do before setting anything but the obvious. An unknown name comes back with the full list."
|
|
19669
19757
|
},
|
|
19670
19758
|
params: {
|
|
19671
19759
|
type: "object",
|
|
@@ -19683,6 +19771,15 @@ var TOOLS = [
|
|
|
19683
19771
|
},
|
|
19684
19772
|
method: "setClipEffect"
|
|
19685
19773
|
},
|
|
19774
|
+
{
|
|
19775
|
+
name: "describe_effect",
|
|
19776
|
+
description: "What an effect's parameters are and what they do: each parameter's key, default and range, and a note on how to use it. Call it before set_clip_effect for any effect whose parameters you are not sure of \u2014 it is cheap, and a guessed parameter name is rejected. With no type, lists every effect by group.",
|
|
19777
|
+
inputSchema: {
|
|
19778
|
+
type: "object",
|
|
19779
|
+
properties: { type: str("Effect type, as set_clip_effect takes it.") }
|
|
19780
|
+
},
|
|
19781
|
+
method: "describeEffect"
|
|
19782
|
+
},
|
|
19686
19783
|
{
|
|
19687
19784
|
name: "set_text_animation",
|
|
19688
19785
|
description: "Give a text clip a staggered entrance \u2014 the words rising one after another, letters typing on, a karaoke highlight sweeping through, a mask wipe travelling over the glyphs. The compositor animates per letter, word or line, so a title arrives instead of cutting on. Naming a preset alone uses the timing it was designed with; unit, stagger and durationIn override it. The response reports how long the entrance takes and warns when it outlasts the clip.",
|
|
@@ -19792,14 +19889,15 @@ var TOOLS = [
|
|
|
19792
19889
|
},
|
|
19793
19890
|
{
|
|
19794
19891
|
name: "add_transition",
|
|
19795
|
-
description:
|
|
19892
|
+
description: 'Put a transition on a clip. edge "in" (the default) is the clip\'s start: mixed with the clip before it on the same track \u2014 transitions between two shots belong to the incoming clip, so pass the id of the SECOND shot \u2014 or, with nothing before it, transitioned in over whatever is below from its first frame. edge "out" is the clip\'s end: its last `duration` seconds take it out to whatever is below (the entrance played backwards for moves, a fade for dissolves) \u2014 for a clip that ends over a gap, the background or lower layers. A duration that does not fit is refused rather than silently ignored.',
|
|
19796
19893
|
inputSchema: {
|
|
19797
19894
|
type: "object",
|
|
19798
19895
|
properties: {
|
|
19799
|
-
clipId: str("The incoming
|
|
19896
|
+
clipId: str("The clip. For a transition between two shots, the incoming one \u2014 the second of the two."),
|
|
19897
|
+
edge: { type: "string", enum: ["in", "out"], description: "in: the clip's start (default). out: the clip's end." },
|
|
19800
19898
|
type: str("Transition name, e.g. crossDissolve, dipToBlack, dipToWhite, slideLeft, wipeRight, zoomIn, whipLeft, fadeToColor."),
|
|
19801
19899
|
duration: num("Length of the transition in seconds."),
|
|
19802
|
-
alignment: { type: "string", enum: ["start", "center", "end"], description:
|
|
19900
|
+
alignment: { type: "string", enum: ["start", "center", "end"], description: 'Where an "in" transition sits relative to the cut. Defaults to center; ignored for "out".' },
|
|
19803
19901
|
color: str("Dip or tint colour, for fadeToColor and the burn/leak presets.")
|
|
19804
19902
|
},
|
|
19805
19903
|
required: ["clipId", "type", "duration"]
|
|
@@ -20057,6 +20155,25 @@ function validateArgs(tool, args) {
|
|
|
20057
20155
|
return null;
|
|
20058
20156
|
}
|
|
20059
20157
|
|
|
20158
|
+
// ../api/_lib/mcp-content.ts
|
|
20159
|
+
var isImage = (v) => !!v && typeof v === "object" && typeof v.data === "string" && typeof v.mimeType === "string";
|
|
20160
|
+
function toContent(result) {
|
|
20161
|
+
if (!result || typeof result !== "object") {
|
|
20162
|
+
return [{ type: "text", text: JSON.stringify(result) ?? "null" }];
|
|
20163
|
+
}
|
|
20164
|
+
const { _image, _images, ...rest } = result;
|
|
20165
|
+
const blocks = [];
|
|
20166
|
+
const captions = [];
|
|
20167
|
+
for (const candidate of [_image, ...Array.isArray(_images) ? _images : []]) {
|
|
20168
|
+
if (!isImage(candidate)) continue;
|
|
20169
|
+
blocks.push({ type: "image", data: candidate.data, mimeType: candidate.mimeType });
|
|
20170
|
+
if (typeof candidate.caption === "string") captions.push(candidate.caption);
|
|
20171
|
+
}
|
|
20172
|
+
const text = captions.length ? JSON.stringify({ ...rest, frameCaptions: captions }) : JSON.stringify(Array.isArray(result) ? result : rest);
|
|
20173
|
+
blocks.push({ type: "text", text });
|
|
20174
|
+
return blocks;
|
|
20175
|
+
}
|
|
20176
|
+
|
|
20060
20177
|
// src/index.ts
|
|
20061
20178
|
var WEBSOCKET_PORT = 9e3;
|
|
20062
20179
|
var BIND_HOST = "127.0.0.1";
|
|
@@ -20101,35 +20218,104 @@ var REQUEST_TIMEOUT_MS = 3e4;
|
|
|
20101
20218
|
var LOCAL_ASSETS_DIR = path.resolve(
|
|
20102
20219
|
process.env.GOATEDIT_ASSETS_DIR || path.join(os.homedir(), "Movies")
|
|
20103
20220
|
);
|
|
20104
|
-
var
|
|
20105
|
-
|
|
20106
|
-
|
|
20221
|
+
var FRAMES_DIR = path.resolve(
|
|
20222
|
+
process.env.GOATEDIT_FRAMES_DIR || path.join(os.homedir(), ".goatedit", "frames")
|
|
20223
|
+
);
|
|
20224
|
+
var VERSION = (() => {
|
|
20225
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20226
|
+
for (const rel of ["../package.json", "../../../package.json"]) {
|
|
20227
|
+
try {
|
|
20228
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, rel), "utf8"));
|
|
20229
|
+
if (pkg.name === "goatedit-mcp") return String(pkg.version);
|
|
20230
|
+
} catch {
|
|
20231
|
+
}
|
|
20232
|
+
}
|
|
20233
|
+
return "0.0.0";
|
|
20234
|
+
})();
|
|
20235
|
+
var INSTRUCTIONS = `Edits a GoatEdit video project live in the user's browser tab at ai.goatedit.com. Call get_timeline before any edit \u2014 clip ids change after every split, trim or delete, and a stale id is rejected. get_timeline is lean by default; pass detail: "full" only when you need effect parameters, and trackId or from/to to read part of it. Several edits in a row go faster as one run_tools call. With more than one tab open, call list_connected_projects, then use_project to pick one. render_preview_frame and preview_media accept saveToDisk: true, which writes the frame to a file and returns its path instead of the image. If a tool reports the editor is not connected, ask the user to open ai.goatedit.com and switch on Local in the AI status menu.`;
|
|
20236
|
+
var MEDIA_TYPES = {
|
|
20237
|
+
".mp4": { mime: "video/mp4", kind: "video" },
|
|
20238
|
+
".m4v": { mime: "video/mp4", kind: "video" },
|
|
20239
|
+
".mov": { mime: "video/quicktime", kind: "video" },
|
|
20240
|
+
".webm": { mime: "video/webm", kind: "video" },
|
|
20241
|
+
".mp3": { mime: "audio/mpeg", kind: "audio" },
|
|
20242
|
+
".wav": { mime: "audio/wav", kind: "audio" },
|
|
20243
|
+
".m4a": { mime: "audio/mp4", kind: "audio" },
|
|
20244
|
+
".aac": { mime: "audio/aac", kind: "audio" },
|
|
20245
|
+
".ogg": { mime: "audio/ogg", kind: "audio" },
|
|
20246
|
+
".flac": { mime: "audio/flac", kind: "audio" },
|
|
20247
|
+
".png": { mime: "image/png", kind: "image" },
|
|
20248
|
+
".jpg": { mime: "image/jpeg", kind: "image" },
|
|
20249
|
+
".jpeg": { mime: "image/jpeg", kind: "image" },
|
|
20250
|
+
".gif": { mime: "image/gif", kind: "image" },
|
|
20251
|
+
".webp": { mime: "image/webp", kind: "image" },
|
|
20252
|
+
".svg": { mime: "image/svg+xml", kind: "image" },
|
|
20253
|
+
".json": { mime: "application/json", kind: "lottie" }
|
|
20107
20254
|
};
|
|
20108
|
-
|
|
20109
|
-
|
|
20255
|
+
function mediaUrl(rel) {
|
|
20256
|
+
return `http://localhost:${WEBSOCKET_PORT}/media/${rel.split(path.sep).map(encodeURIComponent).join("/")}`;
|
|
20257
|
+
}
|
|
20258
|
+
var MAX_LISTED_ASSETS = 500;
|
|
20259
|
+
var MAX_ASSET_DEPTH = 4;
|
|
20260
|
+
async function handleListLocalAssets(args = {}) {
|
|
20110
20261
|
if (!fs.existsSync(LOCAL_ASSETS_DIR)) {
|
|
20111
20262
|
throw new Error(
|
|
20112
20263
|
`Assets directory does not exist: ${LOCAL_ASSETS_DIR}. Set GOATEDIT_ASSETS_DIR to a folder of media.`
|
|
20113
20264
|
);
|
|
20114
20265
|
}
|
|
20115
|
-
const
|
|
20116
|
-
|
|
20117
|
-
|
|
20118
|
-
|
|
20119
|
-
|
|
20120
|
-
|
|
20121
|
-
|
|
20122
|
-
|
|
20123
|
-
|
|
20124
|
-
|
|
20125
|
-
|
|
20126
|
-
|
|
20127
|
-
|
|
20128
|
-
|
|
20129
|
-
|
|
20130
|
-
|
|
20266
|
+
const start = path.resolve(LOCAL_ASSETS_DIR, args.folder || "");
|
|
20267
|
+
if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR + path.sep)) {
|
|
20268
|
+
throw new Error("folder must be inside the assets directory.");
|
|
20269
|
+
}
|
|
20270
|
+
const files = [];
|
|
20271
|
+
let truncated = false;
|
|
20272
|
+
const walk = (dir, depth) => {
|
|
20273
|
+
let entries;
|
|
20274
|
+
try {
|
|
20275
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
20276
|
+
} catch {
|
|
20277
|
+
return;
|
|
20278
|
+
}
|
|
20279
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
20280
|
+
for (const entry of entries) {
|
|
20281
|
+
if (files.length >= MAX_LISTED_ASSETS) {
|
|
20282
|
+
truncated = true;
|
|
20283
|
+
return;
|
|
20284
|
+
}
|
|
20285
|
+
if (entry.name.startsWith(".")) continue;
|
|
20286
|
+
const full = path.join(dir, entry.name);
|
|
20287
|
+
if (entry.isDirectory()) {
|
|
20288
|
+
if (depth < MAX_ASSET_DEPTH) walk(full, depth + 1);
|
|
20289
|
+
continue;
|
|
20290
|
+
}
|
|
20291
|
+
const type = MEDIA_TYPES[path.extname(entry.name).toLowerCase()];
|
|
20292
|
+
if (!type || !entry.isFile()) continue;
|
|
20293
|
+
const rel = path.relative(LOCAL_ASSETS_DIR, full);
|
|
20294
|
+
let bytes = 0;
|
|
20295
|
+
try {
|
|
20296
|
+
bytes = fs.statSync(full).size;
|
|
20297
|
+
} catch {
|
|
20298
|
+
}
|
|
20299
|
+
files.push({ name: entry.name, path: rel, type: type.kind, bytes, url: mediaUrl(rel) });
|
|
20300
|
+
}
|
|
20301
|
+
};
|
|
20302
|
+
walk(start, 0);
|
|
20303
|
+
return {
|
|
20304
|
+
root: LOCAL_ASSETS_DIR,
|
|
20305
|
+
files,
|
|
20306
|
+
...truncated ? { truncated: true, note: `Stopped at ${MAX_LISTED_ASSETS} files. Pass folder to list one subfolder.` } : {}
|
|
20307
|
+
};
|
|
20308
|
+
}
|
|
20309
|
+
function audioDuration(file) {
|
|
20310
|
+
try {
|
|
20311
|
+
const out = execFileSync("afinfo", [file], { encoding: "utf8" });
|
|
20312
|
+
const m = out.match(/estimated duration:\s*([\d.]+)/);
|
|
20313
|
+
return m ? Number(m[1]) : null;
|
|
20314
|
+
} catch {
|
|
20315
|
+
return null;
|
|
20316
|
+
}
|
|
20131
20317
|
}
|
|
20132
|
-
async function handleGenerateVoiceover(text,
|
|
20318
|
+
async function handleGenerateVoiceover(text, projectId) {
|
|
20133
20319
|
if (!text) {
|
|
20134
20320
|
throw new Error("Missing text parameter");
|
|
20135
20321
|
}
|
|
@@ -20143,16 +20329,16 @@ async function handleGenerateVoiceover(text, target) {
|
|
|
20143
20329
|
fs.writeFileSync(scriptPath, text, "utf8");
|
|
20144
20330
|
try {
|
|
20145
20331
|
execFileSync("say", ["-o", filePath, "-f", scriptPath], { stdio: "ignore" });
|
|
20332
|
+
const duration3 = audioDuration(filePath) ?? text.split(/\s+/).length * 0.45 + 1;
|
|
20146
20333
|
const audioAsset = {
|
|
20147
20334
|
id: `media-${crypto.randomUUID()}`,
|
|
20148
20335
|
name: filename,
|
|
20149
20336
|
type: "audio",
|
|
20150
|
-
duration:
|
|
20151
|
-
|
|
20152
|
-
url: `http://localhost:${WEBSOCKET_PORT}/media/${filename}`,
|
|
20337
|
+
duration: duration3,
|
|
20338
|
+
url: mediaUrl(filename),
|
|
20153
20339
|
codec: "pcm_s16le"
|
|
20154
20340
|
};
|
|
20155
|
-
await sendToBrowser("addMediaFile", { file: audioAsset },
|
|
20341
|
+
await sendToBrowser("addMediaFile", { file: audioAsset }, projectId);
|
|
20156
20342
|
return { success: true, file: audioAsset };
|
|
20157
20343
|
} catch (err) {
|
|
20158
20344
|
throw new Error(`Failed to generate voiceover: ${err.message}`);
|
|
@@ -20163,6 +20349,62 @@ async function handleGenerateVoiceover(text, target) {
|
|
|
20163
20349
|
}
|
|
20164
20350
|
}
|
|
20165
20351
|
}
|
|
20352
|
+
function saveFramesToDisk(result, label) {
|
|
20353
|
+
if (!result || typeof result !== "object") return result;
|
|
20354
|
+
const frames = [result._image, ...Array.isArray(result._images) ? result._images : []].filter((f) => f && typeof f.data === "string" && typeof f.mimeType === "string");
|
|
20355
|
+
if (!frames.length) return result;
|
|
20356
|
+
fs.mkdirSync(FRAMES_DIR, { recursive: true });
|
|
20357
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
20358
|
+
const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 60);
|
|
20359
|
+
const saved = frames.map((f, i) => {
|
|
20360
|
+
const ext = f.mimeType === "image/png" ? "png" : f.mimeType === "image/webp" ? "webp" : "jpg";
|
|
20361
|
+
const file = path.join(FRAMES_DIR, `${stamp}-${safe}${frames.length > 1 ? `-${i}` : ""}.${ext}`);
|
|
20362
|
+
const bytes = Buffer.from(f.data, "base64");
|
|
20363
|
+
fs.writeFileSync(file, bytes);
|
|
20364
|
+
return { path: file, bytes: bytes.length, ...f.caption ? { caption: f.caption } : {} };
|
|
20365
|
+
});
|
|
20366
|
+
const { _image, _images, ...rest } = result;
|
|
20367
|
+
return {
|
|
20368
|
+
...rest,
|
|
20369
|
+
...saved.length === 1 ? { savedTo: saved[0].path, bytes: saved[0].bytes } : { savedFrames: saved },
|
|
20370
|
+
note: "Read the path with your own file tools. The image was not inlined."
|
|
20371
|
+
};
|
|
20372
|
+
}
|
|
20373
|
+
function serveFile(req, res, filePath) {
|
|
20374
|
+
const size = fs.statSync(filePath).size;
|
|
20375
|
+
const type = MEDIA_TYPES[path.extname(filePath).toLowerCase()]?.mime ?? "application/octet-stream";
|
|
20376
|
+
const headers = { "Content-Type": type, "Accept-Ranges": "bytes" };
|
|
20377
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || "");
|
|
20378
|
+
if (range && (range[1] || range[2])) {
|
|
20379
|
+
let start;
|
|
20380
|
+
let end;
|
|
20381
|
+
if (range[1]) {
|
|
20382
|
+
start = Number(range[1]);
|
|
20383
|
+
end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
|
|
20384
|
+
} else {
|
|
20385
|
+
start = Math.max(0, size - Number(range[2]));
|
|
20386
|
+
end = size - 1;
|
|
20387
|
+
}
|
|
20388
|
+
if (start > end || start >= size) {
|
|
20389
|
+
res.writeHead(416, { "Content-Range": `bytes */${size}` });
|
|
20390
|
+
res.end();
|
|
20391
|
+
return;
|
|
20392
|
+
}
|
|
20393
|
+
res.writeHead(206, { ...headers, "Content-Length": end - start + 1, "Content-Range": `bytes ${start}-${end}/${size}` });
|
|
20394
|
+
if (req.method === "HEAD") {
|
|
20395
|
+
res.end();
|
|
20396
|
+
return;
|
|
20397
|
+
}
|
|
20398
|
+
fs.createReadStream(filePath, { start, end }).pipe(res);
|
|
20399
|
+
return;
|
|
20400
|
+
}
|
|
20401
|
+
res.writeHead(200, { ...headers, "Content-Length": size });
|
|
20402
|
+
if (req.method === "HEAD") {
|
|
20403
|
+
res.end();
|
|
20404
|
+
return;
|
|
20405
|
+
}
|
|
20406
|
+
fs.createReadStream(filePath).pipe(res);
|
|
20407
|
+
}
|
|
20166
20408
|
var httpServer = http.createServer((req, res) => {
|
|
20167
20409
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
20168
20410
|
const pathname = url.pathname;
|
|
@@ -20180,8 +20422,9 @@ var httpServer = http.createServer((req, res) => {
|
|
|
20180
20422
|
res.setHeader("Vary", "Origin");
|
|
20181
20423
|
if (origin) {
|
|
20182
20424
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
20183
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
20425
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
|
20184
20426
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Range");
|
|
20427
|
+
res.setHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges");
|
|
20185
20428
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
20186
20429
|
}
|
|
20187
20430
|
if (req.method === "OPTIONS") {
|
|
@@ -20189,21 +20432,19 @@ var httpServer = http.createServer((req, res) => {
|
|
|
20189
20432
|
res.end();
|
|
20190
20433
|
return;
|
|
20191
20434
|
}
|
|
20192
|
-
if (pathname.startsWith("/media/")) {
|
|
20193
|
-
|
|
20435
|
+
if (pathname.startsWith("/media/") && (req.method === "GET" || req.method === "HEAD")) {
|
|
20436
|
+
let filename;
|
|
20437
|
+
try {
|
|
20438
|
+
filename = decodeURIComponent(pathname.slice("/media/".length));
|
|
20439
|
+
} catch {
|
|
20440
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
20441
|
+
res.end("Bad path");
|
|
20442
|
+
return;
|
|
20443
|
+
}
|
|
20194
20444
|
const filePath = path.resolve(LOCAL_ASSETS_DIR, filename);
|
|
20195
20445
|
const inRoot = filePath === LOCAL_ASSETS_DIR || filePath.startsWith(LOCAL_ASSETS_DIR + path.sep);
|
|
20196
20446
|
if (inRoot && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
20197
|
-
|
|
20198
|
-
let contentType = "application/octet-stream";
|
|
20199
|
-
if (ext === ".mp4") contentType = "video/mp4";
|
|
20200
|
-
else if (ext === ".mp3") contentType = "audio/mpeg";
|
|
20201
|
-
else if (ext === ".wav") contentType = "audio/wav";
|
|
20202
|
-
else if (ext === ".png") contentType = "image/png";
|
|
20203
|
-
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
|
20204
|
-
else if (ext === ".json") contentType = "application/json";
|
|
20205
|
-
res.writeHead(200, { "Content-Type": contentType });
|
|
20206
|
-
fs.createReadStream(filePath).pipe(res);
|
|
20447
|
+
serveFile(req, res, filePath);
|
|
20207
20448
|
} else {
|
|
20208
20449
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
20209
20450
|
res.end("File not found");
|
|
@@ -20223,16 +20464,61 @@ var wss = new import_websocket_server.default({
|
|
|
20223
20464
|
return isAllowedOrigin(origin);
|
|
20224
20465
|
}
|
|
20225
20466
|
});
|
|
20226
|
-
|
|
20467
|
+
wss.on("error", () => {
|
|
20468
|
+
});
|
|
20469
|
+
var tabs = /* @__PURE__ */ new Map();
|
|
20227
20470
|
var lastActiveProjectId = null;
|
|
20471
|
+
var chosenProjectId = null;
|
|
20228
20472
|
var controllerSockets = /* @__PURE__ */ new Set();
|
|
20229
|
-
|
|
20230
|
-
|
|
20231
|
-
|
|
20232
|
-
|
|
20233
|
-
|
|
20234
|
-
|
|
20235
|
-
|
|
20473
|
+
var controllerRequests = /* @__PURE__ */ new Map();
|
|
20474
|
+
var openTabs = () => [...tabs.values()].filter((t) => t.ws.readyState === import_websocket.default.OPEN);
|
|
20475
|
+
function connectedProjects() {
|
|
20476
|
+
const def = defaultProjectId();
|
|
20477
|
+
return openTabs().map((t) => ({ projectId: t.projectId, name: t.projectName, isDefault: t.projectId === def }));
|
|
20478
|
+
}
|
|
20479
|
+
function defaultProjectId() {
|
|
20480
|
+
const open = openTabs();
|
|
20481
|
+
for (const id of [chosenProjectId, lastActiveProjectId]) {
|
|
20482
|
+
if (id && open.some((t) => t.projectId === id)) return id;
|
|
20483
|
+
}
|
|
20484
|
+
return open[0]?.projectId ?? null;
|
|
20485
|
+
}
|
|
20486
|
+
function notConnectedMessage() {
|
|
20487
|
+
if (!ownsPort) {
|
|
20488
|
+
return `Another goatedit-mcp holds port ${WEBSOCKET_PORT} and this session could not reach it through its controller socket. Close the other session, or check ${CONTROLLER_TOKEN_FILE} is readable.`;
|
|
20489
|
+
}
|
|
20490
|
+
return "GoatEdit editor is not connected. Open ai.goatedit.com and switch on Local in the AI status menu.";
|
|
20491
|
+
}
|
|
20492
|
+
function resolveTab(projectId) {
|
|
20493
|
+
if (projectId) {
|
|
20494
|
+
const tab = tabs.get(projectId);
|
|
20495
|
+
if (tab && tab.ws.readyState === import_websocket.default.OPEN) return tab;
|
|
20496
|
+
const open = connectedProjects();
|
|
20497
|
+
throw new Error(
|
|
20498
|
+
`Project ${projectId} is not open in any connected tab. ` + (open.length ? `Connected: ${open.map((p) => `${p.name ?? "untitled"} (${p.projectId})`).join(", ")}.` : notConnectedMessage())
|
|
20499
|
+
);
|
|
20500
|
+
}
|
|
20501
|
+
const id = defaultProjectId();
|
|
20502
|
+
if (!id) throw new Error(notConnectedMessage());
|
|
20503
|
+
return tabs.get(id);
|
|
20504
|
+
}
|
|
20505
|
+
var pendingRequests = /* @__PURE__ */ new Map();
|
|
20506
|
+
function rejectPendingOn(socket, why) {
|
|
20507
|
+
for (const [id, pending] of pendingRequests) {
|
|
20508
|
+
if (pending.socket !== socket) continue;
|
|
20509
|
+
clearTimeout(pending.timeoutId);
|
|
20510
|
+
pendingRequests.delete(id);
|
|
20511
|
+
pending.reject(new Error(`${why} before ${pending.method} answered. Check the editor tab, then call get_timeline to see whether the edit landed.`));
|
|
20512
|
+
}
|
|
20513
|
+
}
|
|
20514
|
+
function explainTabError(error2) {
|
|
20515
|
+
if (/^Unknown method:/.test(error2)) {
|
|
20516
|
+
return `${error2}. The editor tab and this server disagree on the tool set: reload ai.goatedit.com, or update this server with npx -y goatedit-mcp@latest (running ${VERSION}).`;
|
|
20517
|
+
}
|
|
20518
|
+
return error2;
|
|
20519
|
+
}
|
|
20520
|
+
function sendJson(ws, message) {
|
|
20521
|
+
if (ws.readyState === import_websocket.default.OPEN) ws.send(JSON.stringify(message));
|
|
20236
20522
|
}
|
|
20237
20523
|
wss.on("connection", (ws, req) => {
|
|
20238
20524
|
const url = req.url || "/";
|
|
@@ -20240,193 +20526,357 @@ wss.on("connection", (ws, req) => {
|
|
|
20240
20526
|
console.error("[GoatEdit MCP] Controller client connected");
|
|
20241
20527
|
controllerSockets.add(ws);
|
|
20242
20528
|
ws.on("message", async (data) => {
|
|
20529
|
+
let id;
|
|
20243
20530
|
try {
|
|
20244
20531
|
const message = JSON.parse(data.toString());
|
|
20245
|
-
|
|
20246
|
-
const
|
|
20247
|
-
|
|
20248
|
-
|
|
20249
|
-
|
|
20250
|
-
|
|
20251
|
-
|
|
20252
|
-
|
|
20253
|
-
} else {
|
|
20254
|
-
const target = resolveBrowserSocket(projectId);
|
|
20255
|
-
if (target) {
|
|
20256
|
-
target.send(data.toString());
|
|
20257
|
-
} else {
|
|
20258
|
-
ws.send(JSON.stringify({ id, error: "Browser editor is not connected" }));
|
|
20259
|
-
}
|
|
20532
|
+
id = message.id;
|
|
20533
|
+
const { method } = message;
|
|
20534
|
+
const { projectId: paramProjectId, ...params } = message.params ?? {};
|
|
20535
|
+
const projectId = message.projectId ?? paramProjectId;
|
|
20536
|
+
const local = Object.hasOwn(LOCAL_HANDLERS, method) ? LOCAL_HANDLERS[method] : void 0;
|
|
20537
|
+
if (local) {
|
|
20538
|
+
sendJson(ws, { id, result: await local(params, projectId) });
|
|
20539
|
+
return;
|
|
20260
20540
|
}
|
|
20541
|
+
const target = resolveTab(projectId);
|
|
20542
|
+
if (id !== void 0) controllerRequests.set(String(id), ws);
|
|
20543
|
+
target.ws.send(JSON.stringify({ id, method, params }));
|
|
20261
20544
|
} catch (err) {
|
|
20262
|
-
|
|
20263
|
-
ws.send(JSON.stringify({ error: err.message }));
|
|
20545
|
+
sendJson(ws, { id, error: err?.message ?? String(err) });
|
|
20264
20546
|
}
|
|
20265
20547
|
});
|
|
20266
20548
|
ws.on("close", () => {
|
|
20267
20549
|
console.error("[GoatEdit MCP] Controller client disconnected");
|
|
20268
20550
|
controllerSockets.delete(ws);
|
|
20551
|
+
for (const [id, owner] of controllerRequests) if (owner === ws) controllerRequests.delete(id);
|
|
20269
20552
|
});
|
|
20270
|
-
|
|
20271
|
-
|
|
20272
|
-
|
|
20553
|
+
return;
|
|
20554
|
+
}
|
|
20555
|
+
console.error("[GoatEdit MCP] Browser editor connected via WebSocket");
|
|
20556
|
+
let myProjectId = null;
|
|
20557
|
+
ws.on("message", (data) => {
|
|
20558
|
+
let message;
|
|
20559
|
+
try {
|
|
20560
|
+
message = JSON.parse(data.toString());
|
|
20561
|
+
} catch (err) {
|
|
20562
|
+
console.error("[GoatEdit MCP] Error parsing WebSocket message:", err);
|
|
20563
|
+
return;
|
|
20564
|
+
}
|
|
20565
|
+
if (message.type === "status") {
|
|
20566
|
+
if (typeof message.projectId === "string" && message.projectId) {
|
|
20567
|
+
const pid = message.projectId;
|
|
20568
|
+
if (myProjectId && myProjectId !== pid && tabs.get(myProjectId)?.ws === ws) tabs.delete(myProjectId);
|
|
20569
|
+
myProjectId = pid;
|
|
20570
|
+
tabs.set(pid, { ws, projectId: pid, projectName: message.projectName ?? null });
|
|
20571
|
+
lastActiveProjectId = pid;
|
|
20572
|
+
console.error(`[GoatEdit MCP] Active project: ${message.projectName} (${pid})`);
|
|
20573
|
+
}
|
|
20574
|
+
for (const controller2 of controllerSockets) sendJson(controller2, message);
|
|
20575
|
+
return;
|
|
20576
|
+
}
|
|
20577
|
+
if (message.id === void 0 || message.result === void 0 && message.error === void 0) return;
|
|
20578
|
+
const key = String(message.id);
|
|
20579
|
+
const pending = pendingRequests.get(key);
|
|
20580
|
+
if (pending) {
|
|
20581
|
+
clearTimeout(pending.timeoutId);
|
|
20582
|
+
pendingRequests.delete(key);
|
|
20583
|
+
if (message.error) pending.reject(new Error(explainTabError(String(message.error))));
|
|
20584
|
+
else pending.resolve(message.result);
|
|
20585
|
+
return;
|
|
20586
|
+
}
|
|
20587
|
+
const controller = controllerRequests.get(key);
|
|
20588
|
+
if (controller) {
|
|
20589
|
+
controllerRequests.delete(key);
|
|
20590
|
+
if (controller.readyState === import_websocket.default.OPEN) controller.send(data.toString());
|
|
20591
|
+
}
|
|
20592
|
+
});
|
|
20593
|
+
ws.on("close", () => {
|
|
20594
|
+
console.error("[GoatEdit MCP] Browser editor disconnected");
|
|
20595
|
+
rejectPendingOn(ws, "The editor tab disconnected");
|
|
20596
|
+
for (const [pid, tab] of tabs) {
|
|
20597
|
+
if (tab.ws !== ws) continue;
|
|
20598
|
+
tabs.delete(pid);
|
|
20599
|
+
if (lastActiveProjectId === pid) lastActiveProjectId = null;
|
|
20600
|
+
}
|
|
20601
|
+
});
|
|
20602
|
+
});
|
|
20603
|
+
var ownsPort = false;
|
|
20604
|
+
var upstream = null;
|
|
20605
|
+
var upstreamReady = null;
|
|
20606
|
+
var upstreamPending = /* @__PURE__ */ new Map();
|
|
20607
|
+
function listen() {
|
|
20608
|
+
return new Promise((resolve) => {
|
|
20609
|
+
const onError = (err) => {
|
|
20610
|
+
httpServer.off("listening", onListening);
|
|
20611
|
+
if (err.code !== "EADDRINUSE") {
|
|
20612
|
+
console.error(`[GoatEdit MCP] Could not listen on ${BIND_HOST}:${WEBSOCKET_PORT}: ${err.code || err.message}`);
|
|
20613
|
+
}
|
|
20614
|
+
resolve(false);
|
|
20615
|
+
};
|
|
20616
|
+
const onListening = () => {
|
|
20617
|
+
httpServer.off("error", onError);
|
|
20618
|
+
ownsPort = true;
|
|
20619
|
+
writeControllerToken();
|
|
20620
|
+
console.error(`[GoatEdit MCP] ${VERSION} listening on ${BIND_HOST}:${WEBSOCKET_PORT}`);
|
|
20621
|
+
console.error(`[GoatEdit MCP] Controller token in ${CONTROLLER_TOKEN_FILE}`);
|
|
20622
|
+
console.error(`[GoatEdit MCP] Serving local media from ${LOCAL_ASSETS_DIR}`);
|
|
20623
|
+
resolve(true);
|
|
20624
|
+
};
|
|
20625
|
+
httpServer.once("error", onError);
|
|
20626
|
+
httpServer.once("listening", onListening);
|
|
20627
|
+
httpServer.listen(WEBSOCKET_PORT, BIND_HOST);
|
|
20628
|
+
});
|
|
20629
|
+
}
|
|
20630
|
+
function failUpstream(why) {
|
|
20631
|
+
for (const [id, p] of upstreamPending) {
|
|
20632
|
+
clearTimeout(p.timeoutId);
|
|
20633
|
+
upstreamPending.delete(id);
|
|
20634
|
+
p.reject(new Error(`${why} before ${p.method} answered.`));
|
|
20635
|
+
}
|
|
20636
|
+
}
|
|
20637
|
+
function connectUpstream() {
|
|
20638
|
+
if (upstream && upstream.readyState === import_websocket.default.OPEN) return Promise.resolve(upstream);
|
|
20639
|
+
if (upstreamReady) return upstreamReady;
|
|
20640
|
+
upstreamReady = new Promise((resolve, reject) => {
|
|
20641
|
+
let token;
|
|
20642
|
+
try {
|
|
20643
|
+
token = process.env.GOATEDIT_CONTROLLER_TOKEN || fs.readFileSync(CONTROLLER_TOKEN_FILE, "utf8").trim();
|
|
20644
|
+
} catch {
|
|
20645
|
+
reject(new Error(notConnectedMessage()));
|
|
20646
|
+
return;
|
|
20647
|
+
}
|
|
20648
|
+
const ws = new import_websocket.default(`ws://127.0.0.1:${WEBSOCKET_PORT}/controller`, {
|
|
20649
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
20650
|
+
});
|
|
20651
|
+
ws.once("open", () => {
|
|
20652
|
+
console.error("[GoatEdit MCP] Port held by another goatedit-mcp; working through it as a controller");
|
|
20653
|
+
upstream = ws;
|
|
20654
|
+
resolve(ws);
|
|
20655
|
+
});
|
|
20656
|
+
ws.once("error", (err) => reject(new Error(`${notConnectedMessage()} (${err.message})`)));
|
|
20273
20657
|
ws.on("message", (data) => {
|
|
20658
|
+
let message;
|
|
20274
20659
|
try {
|
|
20275
|
-
|
|
20276
|
-
|
|
20277
|
-
|
|
20278
|
-
if (pending) {
|
|
20279
|
-
clearTimeout(pending.timeoutId);
|
|
20280
|
-
pendingRequests.delete(message.id);
|
|
20281
|
-
if (message.error) {
|
|
20282
|
-
pending.reject(new Error(message.error));
|
|
20283
|
-
} else {
|
|
20284
|
-
pending.resolve(message.result);
|
|
20285
|
-
}
|
|
20286
|
-
}
|
|
20287
|
-
}
|
|
20288
|
-
for (const controller of controllerSockets) {
|
|
20289
|
-
if (controller.readyState === import_websocket.default.OPEN) {
|
|
20290
|
-
controller.send(data.toString());
|
|
20291
|
-
}
|
|
20292
|
-
}
|
|
20293
|
-
if (message.type === "status" && typeof message.projectId === "string" && message.projectId) {
|
|
20294
|
-
const pid = message.projectId;
|
|
20295
|
-
myProjectId = pid;
|
|
20296
|
-
projectSockets.set(pid, ws);
|
|
20297
|
-
lastActiveProjectId = pid;
|
|
20298
|
-
console.error(`[GoatEdit MCP] Active project: ${message.projectName} (${message.projectId})`);
|
|
20299
|
-
}
|
|
20300
|
-
} catch (err) {
|
|
20301
|
-
console.error("[GoatEdit MCP] Error parsing WebSocket message:", err);
|
|
20660
|
+
message = JSON.parse(data.toString());
|
|
20661
|
+
} catch {
|
|
20662
|
+
return;
|
|
20302
20663
|
}
|
|
20664
|
+
if (message.type === "status" || message.id === void 0) return;
|
|
20665
|
+
const pending = upstreamPending.get(String(message.id));
|
|
20666
|
+
if (!pending) return;
|
|
20667
|
+
clearTimeout(pending.timeoutId);
|
|
20668
|
+
upstreamPending.delete(String(message.id));
|
|
20669
|
+
if (message.error) pending.reject(new Error(explainTabError(String(message.error))));
|
|
20670
|
+
else pending.resolve(message.result);
|
|
20303
20671
|
});
|
|
20304
20672
|
ws.on("close", () => {
|
|
20305
|
-
|
|
20306
|
-
|
|
20307
|
-
projectSockets.delete(myProjectId);
|
|
20308
|
-
if (lastActiveProjectId === myProjectId) {
|
|
20309
|
-
lastActiveProjectId = null;
|
|
20310
|
-
}
|
|
20311
|
-
}
|
|
20673
|
+
if (upstream === ws) upstream = null;
|
|
20674
|
+
failUpstream("The goatedit-mcp holding the port went away");
|
|
20312
20675
|
});
|
|
20313
|
-
}
|
|
20314
|
-
|
|
20315
|
-
|
|
20316
|
-
|
|
20317
|
-
}
|
|
20318
|
-
|
|
20319
|
-
|
|
20320
|
-
|
|
20321
|
-
|
|
20322
|
-
|
|
20323
|
-
}
|
|
20324
|
-
function
|
|
20676
|
+
}).finally(() => {
|
|
20677
|
+
upstreamReady = null;
|
|
20678
|
+
});
|
|
20679
|
+
return upstreamReady;
|
|
20680
|
+
}
|
|
20681
|
+
async function ensureTransport() {
|
|
20682
|
+
if (ownsPort) return;
|
|
20683
|
+
if (upstream && upstream.readyState === import_websocket.default.OPEN) return;
|
|
20684
|
+
if (await listen()) return;
|
|
20685
|
+
await connectUpstream();
|
|
20686
|
+
}
|
|
20687
|
+
function sendUpstream(method, params, projectId) {
|
|
20688
|
+
return connectUpstream().then((ws) => new Promise((resolve, reject) => {
|
|
20689
|
+
const id = crypto.randomUUID();
|
|
20690
|
+
const timeoutId = setTimeout(() => {
|
|
20691
|
+
upstreamPending.delete(id);
|
|
20692
|
+
reject(new Error(`Request timed out: ${method}`));
|
|
20693
|
+
}, REQUEST_TIMEOUT_MS);
|
|
20694
|
+
upstreamPending.set(id, { resolve, reject, timeoutId, method });
|
|
20695
|
+
ws.send(JSON.stringify({ id, method, params, ...projectId ? { projectId } : {} }));
|
|
20696
|
+
}));
|
|
20697
|
+
}
|
|
20698
|
+
async function sendToBrowser(method, params = {}, projectId) {
|
|
20699
|
+
await ensureTransport();
|
|
20700
|
+
if (!ownsPort) return sendUpstream(method, params, projectId ?? chosenProjectId);
|
|
20701
|
+
const tab = resolveTab(projectId);
|
|
20325
20702
|
return new Promise((resolve, reject) => {
|
|
20326
|
-
const socket = target ?? resolveBrowserSocket();
|
|
20327
|
-
if (!socket || socket.readyState !== import_websocket.default.OPEN) {
|
|
20328
|
-
return reject(new Error("GoatEdit editor is not connected. Make sure the editor is open in your browser."));
|
|
20329
|
-
}
|
|
20330
20703
|
const id = crypto.randomUUID();
|
|
20331
20704
|
const timeoutId = setTimeout(() => {
|
|
20332
20705
|
pendingRequests.delete(id);
|
|
20333
|
-
reject(new Error(`Request timed out: ${method}
|
|
20706
|
+
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1e3}s: ${method}. Call get_timeline to see whether it landed.`));
|
|
20334
20707
|
}, REQUEST_TIMEOUT_MS);
|
|
20335
|
-
pendingRequests.set(id, { resolve, reject, timeoutId });
|
|
20336
|
-
|
|
20708
|
+
pendingRequests.set(id, { resolve, reject, timeoutId, socket: tab.ws, method });
|
|
20709
|
+
tab.ws.send(JSON.stringify({ id, method, params }));
|
|
20337
20710
|
});
|
|
20338
20711
|
}
|
|
20712
|
+
var LOCAL_HANDLERS = {
|
|
20713
|
+
list_connected_projects: async () => connectedProjects(),
|
|
20714
|
+
list_local_assets: async (args) => handleListLocalAssets(args),
|
|
20715
|
+
generate_voiceover: async (args, projectId) => handleGenerateVoiceover(args.text, projectId)
|
|
20716
|
+
};
|
|
20717
|
+
async function callLocal(method, args, projectId) {
|
|
20718
|
+
await ensureTransport();
|
|
20719
|
+
if (ownsPort) return LOCAL_HANDLERS[method](args, projectId ?? void 0);
|
|
20720
|
+
return sendUpstream(method, args, projectId ?? chosenProjectId);
|
|
20721
|
+
}
|
|
20339
20722
|
var server = new Server(
|
|
20340
|
-
{
|
|
20341
|
-
|
|
20342
|
-
version: "1.0.0"
|
|
20343
|
-
},
|
|
20344
|
-
{
|
|
20345
|
-
capabilities: {
|
|
20346
|
-
tools: {}
|
|
20347
|
-
}
|
|
20348
|
-
}
|
|
20723
|
+
{ name: "goatedit-mcp", version: VERSION },
|
|
20724
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
20349
20725
|
);
|
|
20350
20726
|
var LOCAL_TOOLS = [
|
|
20351
20727
|
{
|
|
20352
20728
|
name: "list_connected_projects",
|
|
20353
|
-
description: "List
|
|
20729
|
+
description: "List the GoatEdit projects open in connected browser tabs, with which one tools act on by default.",
|
|
20354
20730
|
inputSchema: { type: "object", properties: {} }
|
|
20355
20731
|
},
|
|
20732
|
+
{
|
|
20733
|
+
name: "use_project",
|
|
20734
|
+
description: "Pick which connected project every later tool call acts on, when more than one tab is open. Ids come from list_connected_projects.",
|
|
20735
|
+
inputSchema: {
|
|
20736
|
+
type: "object",
|
|
20737
|
+
properties: { projectId: { type: "string", description: "The project to act on." } },
|
|
20738
|
+
required: ["projectId"]
|
|
20739
|
+
}
|
|
20740
|
+
},
|
|
20356
20741
|
{
|
|
20357
20742
|
name: "list_local_assets",
|
|
20358
|
-
description: `List media files
|
|
20359
|
-
inputSchema: {
|
|
20743
|
+
description: `List media files under the local assets directory (${LOCAL_ASSETS_DIR}) and its subfolders, each with a URL this server serves it from. Put one in the project with add_media_file and that URL.`,
|
|
20744
|
+
inputSchema: {
|
|
20745
|
+
type: "object",
|
|
20746
|
+
properties: { folder: { type: "string", description: "Optional subfolder, relative to the assets directory." } }
|
|
20747
|
+
}
|
|
20360
20748
|
},
|
|
20361
20749
|
{
|
|
20362
20750
|
name: "generate_voiceover",
|
|
20363
|
-
description: "Synthesise narration with the macOS `say` voice, write it to the local assets directory, and register it in the project's media bin. macOS only.",
|
|
20751
|
+
description: "Synthesise narration with the macOS `say` voice, write it to the local assets directory, and register it in the project's media bin with its measured duration. macOS only.",
|
|
20752
|
+
inputSchema: {
|
|
20753
|
+
type: "object",
|
|
20754
|
+
properties: { text: { type: "string", description: "The narration to speak." } },
|
|
20755
|
+
required: ["text"]
|
|
20756
|
+
}
|
|
20757
|
+
},
|
|
20758
|
+
{
|
|
20759
|
+
name: "run_tools",
|
|
20760
|
+
description: "Run several tool calls in order in one round trip \u2014 the fast way to make a series of edits. Stops at the first failure unless continueOnError is set, and reports each step's result. Every step sees the edits of the steps before it, but clip ids made by one step are not known when you write the list, so steps should not depend on ids that do not exist yet.",
|
|
20364
20761
|
inputSchema: {
|
|
20365
20762
|
type: "object",
|
|
20366
20763
|
properties: {
|
|
20367
|
-
|
|
20368
|
-
|
|
20764
|
+
calls: {
|
|
20765
|
+
type: "array",
|
|
20766
|
+
description: "The calls, in order.",
|
|
20767
|
+
items: {
|
|
20768
|
+
type: "object",
|
|
20769
|
+
properties: {
|
|
20770
|
+
tool: { type: "string", description: "Tool name, e.g. move_clip." },
|
|
20771
|
+
args: { type: "object", description: "That tool's arguments." }
|
|
20772
|
+
},
|
|
20773
|
+
required: ["tool"]
|
|
20774
|
+
}
|
|
20775
|
+
},
|
|
20776
|
+
continueOnError: { type: "boolean", description: "Run the remaining steps after one fails." }
|
|
20369
20777
|
},
|
|
20370
|
-
required: ["
|
|
20778
|
+
required: ["calls"]
|
|
20371
20779
|
}
|
|
20372
20780
|
}
|
|
20373
20781
|
];
|
|
20374
|
-
var BROWSER_TOOLS = TOOLS.filter((t) => t.method !== null)
|
|
20375
|
-
...t,
|
|
20376
|
-
inputSchema: {
|
|
20377
|
-
...t.inputSchema,
|
|
20378
|
-
properties: { ...t.inputSchema.properties, projectId: PROJECT_ID_PROP }
|
|
20379
|
-
}
|
|
20380
|
-
}));
|
|
20782
|
+
var BROWSER_TOOLS = TOOLS.filter((t) => t.method !== null);
|
|
20381
20783
|
var BROWSER_TOOLS_BY_NAME = new Map(BROWSER_TOOLS.map((t) => [t.name, t]));
|
|
20784
|
+
var SAVES_FRAMES = new Set(
|
|
20785
|
+
BROWSER_TOOLS.filter((t) => "saveToDisk" in (t.inputSchema.properties ?? {})).map((t) => t.name)
|
|
20786
|
+
);
|
|
20382
20787
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
20383
20788
|
tools: [
|
|
20384
20789
|
...LOCAL_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
20385
20790
|
...BROWSER_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))
|
|
20386
20791
|
]
|
|
20387
20792
|
}));
|
|
20793
|
+
async function callTool(name, args) {
|
|
20794
|
+
const { projectId: rawProjectId, ...forwarded } = args;
|
|
20795
|
+
const projectId = typeof rawProjectId === "string" && rawProjectId ? rawProjectId : void 0;
|
|
20796
|
+
switch (name) {
|
|
20797
|
+
case "list_connected_projects":
|
|
20798
|
+
case "list_local_assets":
|
|
20799
|
+
case "generate_voiceover":
|
|
20800
|
+
return callLocal(name, forwarded, projectId);
|
|
20801
|
+
case "use_project": {
|
|
20802
|
+
const wanted = typeof forwarded.projectId === "string" ? forwarded.projectId : projectId;
|
|
20803
|
+
if (!wanted) throw new McpError(ErrorCode.InvalidParams, "Missing required argument: projectId");
|
|
20804
|
+
const open = await callLocal("list_connected_projects", {});
|
|
20805
|
+
const hit = open.find((p) => p.projectId === wanted);
|
|
20806
|
+
if (!hit) {
|
|
20807
|
+
throw new Error(`Project ${wanted} is not connected. Connected: ${open.map((p) => `${p.name ?? "untitled"} (${p.projectId})`).join(", ") || "none"}.`);
|
|
20808
|
+
}
|
|
20809
|
+
chosenProjectId = wanted;
|
|
20810
|
+
return { projectId: wanted, name: hit.name };
|
|
20811
|
+
}
|
|
20812
|
+
case "run_tools":
|
|
20813
|
+
throw new McpError(ErrorCode.InvalidParams, "run_tools cannot be nested.");
|
|
20814
|
+
}
|
|
20815
|
+
const tool = BROWSER_TOOLS_BY_NAME.get(name);
|
|
20816
|
+
if (!tool) {
|
|
20817
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
20818
|
+
}
|
|
20819
|
+
const saveToDisk = SAVES_FRAMES.has(name) && forwarded.saveToDisk === true;
|
|
20820
|
+
if (saveToDisk) delete forwarded.saveToDisk;
|
|
20821
|
+
const invalid = validateArgs(tool, forwarded);
|
|
20822
|
+
if (invalid) throw new McpError(ErrorCode.InvalidParams, invalid);
|
|
20823
|
+
const target = projectId ?? chosenProjectId;
|
|
20824
|
+
const result = await sendToBrowser(tool.method, forwarded, target);
|
|
20825
|
+
return saveToDisk ? saveFramesToDisk(result, name) : result;
|
|
20826
|
+
}
|
|
20827
|
+
async function runTools(args) {
|
|
20828
|
+
const calls = args.calls;
|
|
20829
|
+
if (!Array.isArray(calls) || !calls.length) {
|
|
20830
|
+
throw new McpError(ErrorCode.InvalidParams, "calls must be a non-empty array of { tool, args }.");
|
|
20831
|
+
}
|
|
20832
|
+
const results = [];
|
|
20833
|
+
const images = [];
|
|
20834
|
+
let failed = 0;
|
|
20835
|
+
for (let i = 0; i < calls.length; i++) {
|
|
20836
|
+
const step = calls[i];
|
|
20837
|
+
const tool = typeof step?.tool === "string" ? step.tool : "";
|
|
20838
|
+
try {
|
|
20839
|
+
const raw = await callTool(tool, step.args && typeof step.args === "object" ? step.args : {});
|
|
20840
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
20841
|
+
const { _image, _images, ...rest } = raw;
|
|
20842
|
+
for (const img of [_image, ...Array.isArray(_images) ? _images : []]) {
|
|
20843
|
+
if (img && typeof img === "object") images.push({ ...img, caption: `step ${i + 1} (${tool})` });
|
|
20844
|
+
}
|
|
20845
|
+
results.push({ step: i + 1, tool, result: rest });
|
|
20846
|
+
} else {
|
|
20847
|
+
results.push({ step: i + 1, tool, result: raw });
|
|
20848
|
+
}
|
|
20849
|
+
} catch (err) {
|
|
20850
|
+
failed++;
|
|
20851
|
+
results.push({ step: i + 1, tool, error: err?.message ?? String(err) });
|
|
20852
|
+
if (!args.continueOnError) {
|
|
20853
|
+
if (i + 1 < calls.length) results.push({ skipped: calls.length - i - 1 });
|
|
20854
|
+
break;
|
|
20855
|
+
}
|
|
20856
|
+
}
|
|
20857
|
+
}
|
|
20858
|
+
return { ok: failed === 0, results, ...images.length ? { _images: images } : {} };
|
|
20859
|
+
}
|
|
20388
20860
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
20389
20861
|
const { name, arguments: rawArgs } = request.params;
|
|
20390
20862
|
const args = rawArgs ?? {};
|
|
20391
|
-
const target = resolveBrowserSocket(args.projectId);
|
|
20392
|
-
const forwarded = Object.fromEntries(
|
|
20393
|
-
Object.entries(args).filter(([key]) => key !== "projectId")
|
|
20394
|
-
);
|
|
20395
|
-
const asText = (result) => ({
|
|
20396
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
20397
|
-
});
|
|
20398
20863
|
try {
|
|
20399
|
-
|
|
20400
|
-
|
|
20401
|
-
return asText(
|
|
20402
|
-
[...projectSockets.entries()].filter(([, ws]) => ws.readyState === import_websocket.default.OPEN).map(([id]) => ({ projectId: id, isDefault: id === lastActiveProjectId }))
|
|
20403
|
-
);
|
|
20404
|
-
case "list_local_assets":
|
|
20405
|
-
return asText(await handleListLocalAssets());
|
|
20406
|
-
case "generate_voiceover":
|
|
20407
|
-
return asText(await handleGenerateVoiceover(forwarded.text, target));
|
|
20408
|
-
}
|
|
20409
|
-
const tool = BROWSER_TOOLS_BY_NAME.get(name);
|
|
20410
|
-
if (!tool) {
|
|
20411
|
-
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
20412
|
-
}
|
|
20413
|
-
const invalid = validateArgs(tool, forwarded);
|
|
20414
|
-
if (invalid) throw new McpError(ErrorCode.InvalidParams, invalid);
|
|
20415
|
-
return asText(await sendToBrowser(tool.method, forwarded, target));
|
|
20864
|
+
const result = name === "run_tools" ? await runTools(args) : await callTool(name, args);
|
|
20865
|
+
return { content: toContent(result) };
|
|
20416
20866
|
} catch (error2) {
|
|
20417
20867
|
return {
|
|
20418
20868
|
isError: true,
|
|
20419
|
-
content: [
|
|
20420
|
-
{
|
|
20421
|
-
type: "text",
|
|
20422
|
-
text: error2 instanceof Error ? error2.message : String(error2)
|
|
20423
|
-
}
|
|
20424
|
-
]
|
|
20869
|
+
content: [{ type: "text", text: error2 instanceof Error ? error2.message : String(error2) }]
|
|
20425
20870
|
};
|
|
20426
20871
|
}
|
|
20427
20872
|
});
|
|
20428
20873
|
async function run() {
|
|
20874
|
+
if (!await listen()) {
|
|
20875
|
+
connectUpstream().catch((err) => console.error(`[GoatEdit MCP] ${err.message}`));
|
|
20876
|
+
}
|
|
20429
20877
|
const transport = new StdioServerTransport();
|
|
20878
|
+
process.stdin.on("end", () => process.exit(0));
|
|
20879
|
+
process.stdin.on("close", () => process.exit(0));
|
|
20430
20880
|
await server.connect(transport);
|
|
20431
20881
|
console.error("[GoatEdit MCP] Server running on stdio transport");
|
|
20432
20882
|
}
|
package/package.json
CHANGED