goatedit-mcp 1.1.0 → 1.1.2

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 CHANGED
@@ -112,6 +112,10 @@ up to 500 files); `/media/<path>` serves from it with byte ranges, so video
112
112
  seeks, and paths that resolve outside it are a 404. Generated voiceovers are
113
113
  written there too.
114
114
 
115
+ `import_local_file` looks in that folder first: a file found there (by name,
116
+ or by the path `list_local_assets` gives) is imported from this server, with no
117
+ GoatEdit bridge needed. Only a miss falls through to the bridge.
118
+
115
119
  ---
116
120
 
117
121
  ## Tools
@@ -3232,8 +3232,8 @@ var require_utils = __commonJS({
3232
3232
  }
3233
3233
  return ind;
3234
3234
  }
3235
- function removeDotSegments(path2) {
3236
- let input = path2;
3235
+ function removeDotSegments(path3) {
3236
+ let input = path3;
3237
3237
  const output = [];
3238
3238
  let nextSlash = -1;
3239
3239
  let len = 0;
@@ -3485,8 +3485,8 @@ var require_schemes = __commonJS({
3485
3485
  wsComponent.secure = void 0;
3486
3486
  }
3487
3487
  if (wsComponent.resourceName) {
3488
- const [path2, query] = wsComponent.resourceName.split("?");
3489
- wsComponent.path = path2 && path2 !== "/" ? path2 : void 0;
3488
+ const [path3, query] = wsComponent.resourceName.split("?");
3489
+ wsComponent.path = path3 && path3 !== "/" ? path3 : void 0;
3490
3490
  wsComponent.query = query;
3491
3491
  wsComponent.resourceName = void 0;
3492
3492
  }
@@ -6879,12 +6879,12 @@ var require_dist = __commonJS({
6879
6879
  throw new Error(`Unknown format "${name}"`);
6880
6880
  return f;
6881
6881
  };
6882
- function addFormats(ajv, list, fs2, exportName) {
6882
+ function addFormats(ajv, list, fs3, exportName) {
6883
6883
  var _a3;
6884
6884
  var _b;
6885
6885
  (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
6886
6886
  for (const f of list)
6887
- ajv.addFormat(f, fs2[f]);
6887
+ ajv.addFormat(f, fs3[f]);
6888
6888
  }
6889
6889
  module.exports = exports = formatsPlugin;
6890
6890
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -10811,10 +10811,10 @@ function mergeDefs(...defs) {
10811
10811
  function cloneDef(schema) {
10812
10812
  return mergeDefs(schema._zod.def);
10813
10813
  }
10814
- function getElementAtPath(obj, path2) {
10815
- if (!path2)
10814
+ function getElementAtPath(obj, path3) {
10815
+ if (!path3)
10816
10816
  return obj;
10817
- return path2.reduce((acc, key) => acc?.[key], obj);
10817
+ return path3.reduce((acc, key) => acc?.[key], obj);
10818
10818
  }
10819
10819
  function promiseAllObject(promisesObj) {
10820
10820
  const keys = Object.keys(promisesObj);
@@ -11223,11 +11223,11 @@ function explicitlyAborted(x, startIndex = 0) {
11223
11223
  }
11224
11224
  return false;
11225
11225
  }
11226
- function prefixIssues(path2, issues) {
11226
+ function prefixIssues(path3, issues) {
11227
11227
  return issues.map((iss) => {
11228
11228
  var _a3;
11229
11229
  (_a3 = iss).path ?? (_a3.path = []);
11230
- iss.path.unshift(path2);
11230
+ iss.path.unshift(path3);
11231
11231
  return iss;
11232
11232
  });
11233
11233
  }
@@ -11374,16 +11374,16 @@ function flattenError(error2, mapper = (issue2) => issue2.message) {
11374
11374
  }
11375
11375
  function formatError(error2, mapper = (issue2) => issue2.message) {
11376
11376
  const fieldErrors = { _errors: [] };
11377
- const processError = (error3, path2 = []) => {
11377
+ const processError = (error3, path3 = []) => {
11378
11378
  for (const issue2 of error3.issues) {
11379
11379
  if (issue2.code === "invalid_union" && issue2.errors.length) {
11380
- issue2.errors.map((issues) => processError({ issues }, [...path2, ...issue2.path]));
11380
+ issue2.errors.map((issues) => processError({ issues }, [...path3, ...issue2.path]));
11381
11381
  } else if (issue2.code === "invalid_key") {
11382
- processError({ issues: issue2.issues }, [...path2, ...issue2.path]);
11382
+ processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
11383
11383
  } else if (issue2.code === "invalid_element") {
11384
- processError({ issues: issue2.issues }, [...path2, ...issue2.path]);
11384
+ processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
11385
11385
  } else {
11386
- const fullpath = [...path2, ...issue2.path];
11386
+ const fullpath = [...path3, ...issue2.path];
11387
11387
  if (fullpath.length === 0) {
11388
11388
  fieldErrors._errors.push(mapper(issue2));
11389
11389
  } else {
@@ -19144,8 +19144,8 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
19144
19144
  // src/index.ts
19145
19145
  import crypto from "crypto";
19146
19146
  import http from "http";
19147
- import fs from "fs";
19148
- import path from "path";
19147
+ import fs2 from "fs";
19148
+ import path2 from "path";
19149
19149
  import { execFileSync } from "child_process";
19150
19150
  import os from "os";
19151
19151
  import { fileURLToPath } from "url";
@@ -19155,6 +19155,14 @@ var num = (description) => ({ type: "number", description });
19155
19155
  var str = (description) => ({ type: "string", description });
19156
19156
  var bool = (description) => ({ type: "boolean", description });
19157
19157
  var CLIP_ID = str("ID of the clip, as returned by get_timeline.");
19158
+ var SKIP_ADJUSTMENTS = bool(
19159
+ "Render without any adjustment layer (a global camera move, a grade). For frames you will bake into a clip that sits under that layer, which would otherwise apply it twice. The timeline is not changed."
19160
+ );
19161
+ var HIDE_CLIP_IDS = {
19162
+ type: "array",
19163
+ items: { type: "string" },
19164
+ description: "Render as if these clips were not there. The timeline is not changed."
19165
+ };
19158
19166
  var TEXT_STYLE_PROPS = {
19159
19167
  fontSize: num("Font size in pixels, authored against a 1080p-tall frame."),
19160
19168
  fontFamily: str("Font family name."),
@@ -19171,10 +19179,24 @@ var TEXT_STYLE_PROPS = {
19171
19179
  textShadowColor: str("Drop shadow colour."),
19172
19180
  lineHeight: num("Line advance as a multiple of the line height. Defaults to 1.2."),
19173
19181
  letterSpacing: num("Extra tracking in pixels @1080p."),
19182
+ textUnderline: bool("Underline the text."),
19183
+ textShadowBlur: num("Drop shadow softness in pixels @1080p. 0 is a hard shadow."),
19184
+ textShadowOffsetX: num("Drop shadow offset to the right, pixels @1080p."),
19185
+ textShadowOffsetY: num("Drop shadow offset downward, pixels @1080p."),
19186
+ textGradientStart: str("Fill the words with a gradient from this colour (top) \u2026"),
19187
+ textGradientEnd: str("\u2026 to this one (bottom). Set both."),
19188
+ textAccentColor: str("Colour for accent words. Mark an accent word in the text as *word*."),
19189
+ textAccentFontFamily: str(`Font for accent words, e.g. "'Great Vibes', cursive" for a script word inside heavy caps.`),
19190
+ textAccentScale: num("Size of accent words relative to the rest, e.g. 1.8."),
19191
+ textAccentGradientStart: str("Gradient start colour for accent words."),
19192
+ textAccentGradientEnd: str("Gradient end colour for accent words."),
19193
+ textSwooshUnderline: bool("Hand-drawn swoosh under the last line."),
19194
+ textSticker: { type: "string", enum: ["ghost", "arrow", "pen", "star", "flame"], description: "Small sticker drawn beside the accent word." },
19195
+ textLayoutMode: { type: "string", enum: ["side-grid", "luxury-stack", "editorial-hero", "slanted-red", "double-hero"], description: "Editorial layout engine for multi-word titles." },
19174
19196
  posX: num("Horizontal position as a fraction of the frame, 0..1. 0.5 is centre."),
19175
19197
  posY: num("Vertical position as a fraction of the frame, 0..1. 0.5 is centre.")
19176
19198
  };
19177
- var TOOLS = [
19199
+ var BASE_TOOLS = [
19178
19200
  {
19179
19201
  name: "get_session_status",
19180
19202
  description: "Check whether a GoatEdit editor tab is currently attached to this session, and which project it has open. Call this first if any other tool reports the editor is not connected.",
@@ -19237,7 +19259,9 @@ var TOOLS = [
19237
19259
  description: "Write the frame to disk through the local bridge and return its path instead of the image itself. A frame is around 200KB, and inline it is paid for twice \u2014 as tokens and as a cached object \u2014 so a review pass over twenty cuts is expensive. With this set, read the returned path with your own file tools. Falls back to an inline image, with a warning, when the bridge is not running."
19238
19260
  },
19239
19261
  width: num("Width of the returned image in pixels, up to 1280. Defaults to 1024."),
19240
- outlines: bool(`Draw every layer's box, handles and anchor point over the picture, with a timecode \u2014 the look of the editor's selection, on every layer at once. The same option the Export dialog offers as "Show layer outlines".`)
19262
+ outlines: bool(`Draw every layer's box, handles and anchor point over the picture, with a timecode \u2014 the look of the editor's selection, on every layer at once. The same option the Export dialog offers as "Show layer outlines".`),
19263
+ skipAdjustments: SKIP_ADJUSTMENTS,
19264
+ hideClipIds: HIDE_CLIP_IDS
19241
19265
  }
19242
19266
  },
19243
19267
  method: "renderFrame"
@@ -19263,7 +19287,9 @@ var TOOLS = [
19263
19287
  startTime: num("Start of the range in seconds."),
19264
19288
  endTime: num("End of the range in seconds. Ranges over 10 minutes are refused."),
19265
19289
  frames: num("How many frames to sample, up to 6. Defaults to 4."),
19266
- width: num("Width of each frame in pixels. Defaults to 640.")
19290
+ width: num("Width of each frame in pixels. Defaults to 640."),
19291
+ skipAdjustments: SKIP_ADJUSTMENTS,
19292
+ hideClipIds: HIDE_CLIP_IDS
19267
19293
  },
19268
19294
  required: ["startTime", "endTime"]
19269
19295
  },
@@ -19356,7 +19382,7 @@ var TOOLS = [
19356
19382
  },
19357
19383
  {
19358
19384
  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.',
19385
+ description: 'Get just the timeline \u2014 all tracks with their clips, ids, start times and durations. Cheaper than get_project_state; use it before your first edit so you have current clip ids. After that, each edit\u2019s reply lists the clips it added, changed and removed under timelineChanges, so a re-read is only needed when that says it was truncated. 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; get_clip returns them. 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
19386
  inputSchema: {
19361
19387
  type: "object",
19362
19388
  properties: {
@@ -19368,6 +19394,40 @@ var TOOLS = [
19368
19394
  },
19369
19395
  method: "getTimeline"
19370
19396
  },
19397
+ {
19398
+ name: "get_clip",
19399
+ description: "Get clips in full, animation included: every keyframe on every property (time in seconds from the clip\u2019s start, value, ease \u2014 the shape set_keyframes takes), effect parameters and their keyframes, mask shapes, crop, key and transform. Use it to copy or adjust a clip\u2019s animation; get_timeline only counts keyframes. Per-frame data (motion tracking, roto point sets per frame, caption word timings) stays a count, as does any property with more than 300 keyframes.",
19400
+ inputSchema: {
19401
+ type: "object",
19402
+ properties: {
19403
+ clipIds: { type: "array", items: { type: "string" }, description: "Clip ids from get_timeline, up to 50." }
19404
+ },
19405
+ required: ["clipIds"]
19406
+ },
19407
+ method: "getClips"
19408
+ },
19409
+ {
19410
+ name: "create_checkpoint",
19411
+ description: "Save a copy of the whole project (every sequence and the media bin) to return to with restore_checkpoint. Take one before a long scripted change: undo goes back one step at a time and a big rebuild can outrun it. Kept in the editor tab until it reloads, the newest 20 per project.",
19412
+ inputSchema: { type: "object", properties: { name: str("Optional label.") } },
19413
+ method: "createCheckpoint"
19414
+ },
19415
+ {
19416
+ name: "list_checkpoints",
19417
+ description: "List the checkpoints saved in this tab for the open project.",
19418
+ inputSchema: { type: "object", properties: {} },
19419
+ method: "listCheckpoints"
19420
+ },
19421
+ {
19422
+ name: "restore_checkpoint",
19423
+ description: "Put the whole project back as it was at a checkpoint, as one undoable step. Everything done since is undone at once, in every sequence.",
19424
+ inputSchema: {
19425
+ type: "object",
19426
+ properties: { checkpointId: str("Id from create_checkpoint or list_checkpoints.") },
19427
+ required: ["checkpointId"]
19428
+ },
19429
+ method: "restoreCheckpoint"
19430
+ },
19371
19431
  {
19372
19432
  name: "get_media_bin",
19373
19433
  description: "List every media file imported into the project, with id, name, type and duration.",
@@ -19418,7 +19478,7 @@ var TOOLS = [
19418
19478
  inputSchema: {
19419
19479
  type: "object",
19420
19480
  properties: {
19421
- trackId: str('Target track id from get_timeline, or "__new_video__" to let the editor pick a free video track.'),
19481
+ trackId: str('Target track id from get_timeline, "__new_video__" to let the editor pick a free video track, or "__new_track__" for a new video track on top.'),
19422
19482
  mediaFileId: str("Media bin file id from get_media_bin."),
19423
19483
  startTime: num("Where the clip starts on the timeline, in seconds."),
19424
19484
  duration: num("Length of the clip on the timeline, in seconds."),
@@ -19525,6 +19585,152 @@ var TOOLS = [
19525
19585
  },
19526
19586
  method: "addShapeClip"
19527
19587
  },
19588
+ {
19589
+ name: "update_shape_clip",
19590
+ description: "Change a shape after it was made \u2014 fill, size, corners, outline, and a dashed or rounded outline \u2014 without deleting it and losing its id, keyframes and effects.",
19591
+ inputSchema: {
19592
+ type: "object",
19593
+ properties: {
19594
+ clipId: CLIP_ID,
19595
+ color: str("Fill colour."),
19596
+ width: num("Width in design units against a 1080p-tall frame."),
19597
+ height: num("Height in design units against a 1080p-tall frame."),
19598
+ cornerRadius: num("Corner radius in design units."),
19599
+ strokeColor: str("Outline colour."),
19600
+ strokeWidth: num("Outline width in design units."),
19601
+ strokeCap: { type: "string", enum: ["butt", "round", "square"], description: "Ends of an open or dashed outline." },
19602
+ strokeJoin: { type: "string", enum: ["miter", "round", "bevel"], description: "Outline corners." },
19603
+ dash: num("Dash length for a dashed outline, design units. 0 for solid."),
19604
+ gap: num("Gap between dashes, design units.")
19605
+ },
19606
+ required: ["clipId"]
19607
+ },
19608
+ method: "updateShapeClip"
19609
+ },
19610
+ {
19611
+ name: "add_null",
19612
+ description: "Add a Null Object: an invisible handle that other clips are parented to (set_parent). Move, scale or rotate the null and everything under it follows, orbiting its position \u2014 the After Effects way to group a polaroid with its caption, or make a whole layout drift as one.",
19613
+ inputSchema: {
19614
+ type: "object",
19615
+ properties: {
19616
+ startTime: num("Seconds. Defaults to 0."),
19617
+ duration: num("Seconds."),
19618
+ x: num("Position as a fraction of the frame width. Defaults to 0.5."),
19619
+ y: num("Position as a fraction of the frame height. Defaults to 0.5."),
19620
+ name: str("Display name."),
19621
+ trackId: str('Video track, or "__new_video__" (default) for a free one.')
19622
+ },
19623
+ required: ["duration"]
19624
+ },
19625
+ method: "addNull"
19626
+ },
19627
+ {
19628
+ name: "set_parent",
19629
+ description: "Parent a clip to another clip or null, After Effects style. The clip stays exactly where it is when parented; from then on it follows the parent's move, scale and rotation, orbiting the parent's position and spreading as it scales. parentId null unparents. position / scale / rotation pick-whip a single channel to a different clip (null cuts that channel loose): a channel parent passes on only its own change. Parent first, then animate the parent.",
19630
+ inputSchema: {
19631
+ type: "object",
19632
+ properties: {
19633
+ clipId: CLIP_ID,
19634
+ parentId: { type: ["string", "null"], description: "Parent clip id, or null to unparent." },
19635
+ position: { type: ["string", "null"], description: "Take position only from this clip." },
19636
+ scale: { type: ["string", "null"], description: "Take scale only from this clip." },
19637
+ rotation: { type: ["string", "null"], description: "Take rotation only from this clip." }
19638
+ },
19639
+ required: ["clipId"]
19640
+ },
19641
+ method: "setParent"
19642
+ },
19643
+ {
19644
+ name: "group_clips",
19645
+ description: "Group clips under a new null spanning all of them, so they move, scale and rotate as one; each keeps its place. Returns the group's clipId \u2014 animate that. With layout set, the null becomes an Auto Layout frame and the clips flow inside it (tune it with set_auto_layout).",
19646
+ inputSchema: {
19647
+ type: "object",
19648
+ properties: {
19649
+ clipIds: { type: "array", items: { type: "string" }, description: "Clips to group." },
19650
+ name: str("Group name."),
19651
+ layout: { type: "string", enum: ["none", "horizontal", "vertical", "wrap", "orbit"], description: "Defaults to none: a plain group." },
19652
+ gap: num("Gap between laid-out clips, design units. Defaults to 16."),
19653
+ x: num("Group pivot, fraction of the frame width. Defaults to the members' average."),
19654
+ y: num("Group pivot, fraction of the frame height."),
19655
+ trackId: str('Video track for the null, or "__new_video__" (default).')
19656
+ },
19657
+ required: ["clipIds"]
19658
+ },
19659
+ method: "groupClips"
19660
+ },
19661
+ {
19662
+ name: "set_proximity",
19663
+ description: "Drive one property of a clip by how close it is to another clip on screen \u2014 a card that brightens as a cursor nears it, a label that grows when a photo slides over it. Distance is measured between where both are drawn (parents and layout included), as a fraction of the frame width. At `near` or closer the property is nearValue, at `far` or beyond it is farValue, linear between.",
19664
+ inputSchema: {
19665
+ type: "object",
19666
+ properties: {
19667
+ clipId: CLIP_ID,
19668
+ targetClipId: str("The clip whose distance drives it."),
19669
+ property: { type: "string", enum: ["opacity", "scaleX", "scaleY", "posX", "posY", "rotation"], description: "Defaults to opacity." },
19670
+ near: num("Distance for nearValue. Defaults to 0.05."),
19671
+ far: num("Distance for farValue. Defaults to 0.3."),
19672
+ nearValue: num("Defaults to 1."),
19673
+ farValue: num("Defaults to 0."),
19674
+ remove: bool("Remove the driver.")
19675
+ },
19676
+ required: ["clipId"]
19677
+ },
19678
+ method: "setProximity"
19679
+ },
19680
+ {
19681
+ name: "set_clip_speed",
19682
+ description: "Retime a video or audio clip like the Speed panel: speed (0.1\u201310) keeps the source range and changes the clip length (stopping at the next clip); ramp applies a speed curve \u2014 a preset name (montage, hero, bullet, jumpCut, flashIn, flashOut) or [{x 0..1, y rate}] points; null flattens it. reversed plays it backwards; holdFrame freezes from that many seconds into the clip (null releases). Linked audio is retimed with it.",
19683
+ inputSchema: {
19684
+ type: "object",
19685
+ properties: {
19686
+ clipId: CLIP_ID,
19687
+ speed: num("Playback rate, 1 = normal."),
19688
+ ramp: { type: ["string", "array", "null"], description: "Preset id, or points [{x, y}], or null." },
19689
+ reversed: bool("Play backwards."),
19690
+ holdFrame: { type: ["number", "null"], description: "Freeze from this clip-relative time; null removes the hold." }
19691
+ },
19692
+ required: ["clipId"]
19693
+ },
19694
+ method: "setClipSpeed"
19695
+ },
19696
+ {
19697
+ name: "set_auto_layout",
19698
+ description: "Figma-style Auto Layout. On a frame (group_clips with a layout, or any clip with children): mode, gap, padding, alignment, resizing, clip content, reverse, stack order, orbit settings. Children are spaced by the room they take at their own scale, and the frame's own scale and rotation act on the whole arrangement. On a child: absolute (leave the flow and sit at its own position inside the frame), grow (share of free space when resizing is fill), min/max size.",
19699
+ inputSchema: {
19700
+ type: "object",
19701
+ properties: {
19702
+ clipId: CLIP_ID,
19703
+ enabled: bool("Switch the layout on or off without losing it."),
19704
+ mode: { type: "string", enum: ["none", "horizontal", "vertical", "wrap", "orbit"] },
19705
+ gap: num("Gap between children, design units."),
19706
+ gapV: num("Gap between rows in wrap mode."),
19707
+ padding: num("All four paddings at once."),
19708
+ paddingLeft: num("Design units."),
19709
+ paddingRight: num("Design units."),
19710
+ paddingTop: num("Design units."),
19711
+ paddingBottom: num("Design units."),
19712
+ primaryAlign: { type: "string", enum: ["start", "center", "end", "space-between", "space-around", "space-evenly"] },
19713
+ counterAlign: { type: "string", enum: ["start", "center", "end", "stretch"] },
19714
+ horizontalResizing: { type: "string", enum: ["fixed", "hug", "fill"] },
19715
+ verticalResizing: { type: "string", enum: ["fixed", "hug", "fill"] },
19716
+ clipContent: bool("Clip children to the frame."),
19717
+ reverse: bool("Reverse the flow order."),
19718
+ stackOrder: { type: "string", enum: ["firstOnTop", "lastOnTop"] },
19719
+ orbitRadius: num("Orbit mode radius, design units."),
19720
+ orbitSpeed: num("Orbit mode degrees per second."),
19721
+ orbitStartAngle: num("Orbit mode start angle, degrees."),
19722
+ orbitRotateChildren: bool("Turn children to face along the orbit."),
19723
+ absolute: bool("Child only: leave the flow."),
19724
+ grow: num("Child only: share of free space with fill resizing."),
19725
+ minWidth: num("Design units."),
19726
+ maxWidth: num("Design units."),
19727
+ minHeight: num("Design units."),
19728
+ maxHeight: num("Design units.")
19729
+ },
19730
+ required: ["clipId"]
19731
+ },
19732
+ method: "setAutoLayout"
19733
+ },
19528
19734
  {
19529
19735
  name: "set_shape_gradient",
19530
19736
  description: "Give a shape or a pen path a gradient fill or a gradient stroke, replacing its flat colour. Works on anything with a shapeType, including paths drawn with the pen tool. The ramp is laid out against the shape's own bounding box, so it stays put on the object when the shape is moved, scaled or rotated rather than sliding across it. Pass null to clear one and go back to the flat colour. gradientKeyframes animates it \u2014 a sweep that turns, a glow that shifts colour.",
@@ -19695,12 +19901,69 @@ var TOOLS = [
19695
19901
  scaleX: num("Horizontal scale, overriding scale."),
19696
19902
  scaleY: num("Vertical scale, overriding scale."),
19697
19903
  rotation: num("Rotation in degrees."),
19698
- opacity: num("Opacity from 0 to 1.")
19904
+ opacity: num("Opacity from 0 to 1."),
19905
+ blendMode: {
19906
+ type: "string",
19907
+ enum: [
19908
+ "normal",
19909
+ "multiply",
19910
+ "screen",
19911
+ "overlay",
19912
+ "darken",
19913
+ "lighten",
19914
+ "color-dodge",
19915
+ "color-burn",
19916
+ "hard-light",
19917
+ "soft-light",
19918
+ "difference",
19919
+ "exclusion",
19920
+ "hue",
19921
+ "saturation",
19922
+ "color",
19923
+ "luminosity"
19924
+ ],
19925
+ description: "How the clip mixes with the layers beneath it. screen for light leaks, flares and dust shot on black; multiply for paper textures, ink and shadows shot on white; overlay or soft-light for texture over a picture. normal sets it back."
19926
+ }
19699
19927
  },
19700
19928
  required: ["clipId"]
19701
19929
  },
19702
19930
  method: "setClipTransform"
19703
19931
  },
19932
+ {
19933
+ name: "set_ultra_key",
19934
+ description: "Green screen: knock a colour out of a video or image clip so the tracks beneath show through \u2014 a paper unfold, a smoke burst or a presenter shot on green or blue. Unset values keep what the clip already has (or the defaults: green, tolerance 0.18, softness 0.1, spill 0.4). Raise tolerance when green is left behind, softness for a gentler edge, spill when edges carry a green tint. Check the result with render_preview_frame; pass remove to take the key off.",
19935
+ inputSchema: {
19936
+ type: "object",
19937
+ properties: {
19938
+ clipId: CLIP_ID,
19939
+ keyColor: str('Colour to remove, as "#rrggbb". Defaults to "#00ff00"; pick "#0000ff" for blue screen, or the sampled backdrop colour when the green is off-pure.'),
19940
+ tolerance: num("How close to the key colour a pixel must be to vanish, 0..1. Defaults to 0.18."),
19941
+ softness: num("Width of the soft edge around the key, 0..1. Defaults to 0.1."),
19942
+ spill: num("How strongly green fringing is desaturated on edges, 0..1. Defaults to 0.4."),
19943
+ enabled: bool("Switch the key off without losing its settings. Defaults to true."),
19944
+ remove: bool("Remove the key entirely.")
19945
+ },
19946
+ required: ["clipId"]
19947
+ },
19948
+ method: "setUltraKey"
19949
+ },
19950
+ {
19951
+ name: "set_track_matte",
19952
+ description: "After Effects track matte: another clip\u2019s transparency (alpha) or brightness (luma) becomes this clip\u2019s transparency, optionally inverted. Use it to reveal an image through a moving shape \u2014 a torn-paper or ink-bleed clip, a wipe, text \u2014 without baking. The matte clip stops drawing once it is used as a matte unless keepSourceVisible is set. Name the matte with sourceClipId (any clip, on any track; give it the same transform as the clip it cuts); without one, the matte is whatever is live on the nearest video track above. Unset values keep what the clip already has. Pass remove to take the matte off.",
19953
+ inputSchema: {
19954
+ type: "object",
19955
+ properties: {
19956
+ clipId: CLIP_ID,
19957
+ type: { type: "string", enum: ["alpha", "alphaInverted", "luma", "lumaInverted"], description: "Defaults to alpha." },
19958
+ sourceClipId: str("Clip that supplies the matte."),
19959
+ keepSourceVisible: bool("Keep drawing the matte clip as well. Defaults to false."),
19960
+ enabled: bool("Switch the matte off without losing it. Defaults to true."),
19961
+ remove: bool("Remove the matte.")
19962
+ },
19963
+ required: ["clipId"]
19964
+ },
19965
+ method: "setTrackMatte"
19966
+ },
19704
19967
  {
19705
19968
  name: "set_keyframes",
19706
19969
  description: "Animate one property of a clip over time. set_clip_transform sets a value; this is what makes it move \u2014 a card sliding on, a background drifting, a photo pushing in. Times are in seconds RELATIVE TO THE CLIP's start, and each keyframe may carry its own outgoing easing as a CSS cubic-bezier ([0.22, 1, 0.36, 1] is a good decelerate). Two or more keyframes make motion; one holds a value. Writing keyframes for a property overrides whatever flat value the clip carries for it, and replaces any track already on that property \u2014 pass clear to remove one.",
@@ -19780,6 +20043,70 @@ var TOOLS = [
19780
20043
  },
19781
20044
  method: "describeEffect"
19782
20045
  },
20046
+ {
20047
+ name: "list_presets",
20048
+ description: "List animation presets. The user\u2019s own saved presets always come back in full; the built-in library (a couple of hundred title moves, lower thirds, logo reveals, backgrounds and more) is summarised by category unless you pass category or query. Apply one with apply_preset.",
20049
+ inputSchema: {
20050
+ type: "object",
20051
+ properties: {
20052
+ category: {
20053
+ type: "string",
20054
+ enum: ["text", "motion", "paper", "liquid", "glitch", "logo", "lower-third", "transition", "bg"],
20055
+ description: "List the built-in presets of one category."
20056
+ },
20057
+ query: str("Search built-in presets by name, description or tag.")
20058
+ }
20059
+ },
20060
+ method: "listPresets"
20061
+ },
20062
+ {
20063
+ name: "save_preset",
20064
+ description: 'Save clips as a reusable preset: their transform keyframes (stored as fractions of the clip, so the move stretches to whatever it is applied to), effects, transition, and text or shape styling. It is kept in this browser and shows under My Presets in the Presets panel, across reloads. One clip makes a preset that can be laid onto another clip; several make a group that is added as new clips. Or pass `preset` instead of clipIds to save a definition directly \u2014 the only way to make a preset with media layers: each layer is a video or image (by URL) sized to the clip the preset is applied to, drawn over it (role "over") or cutting it as its track matte (role "matte"), e.g. a torn-paper overlay plus a luma matte that reveals a photo.',
20065
+ inputSchema: {
20066
+ type: "object",
20067
+ properties: {
20068
+ clipIds: { type: "array", items: { type: "string" }, description: "The clips to capture." },
20069
+ preset: {
20070
+ type: "object",
20071
+ description: 'A preset definition instead of clipIds: { name, category?, description?, tags?, id? (custom_\u2026 to replace one), clips?: [], layers: [{ label, role: "over"|"matte", matteType?: "alpha"|"alphaInverted"|"luma"|"lumaInverted", start (s from the clip start), dur?, followLongSide? (quarter-turn to match a portrait clip), copyEffects? (effect types copied from the clip, e.g. ["dropShadow"]), blendMode?, media: { url, name, type: "video"|"image", duration, width, height } }] }.'
20072
+ },
20073
+ name: str("Preset name."),
20074
+ category: {
20075
+ type: "string",
20076
+ enum: ["text", "motion", "paper", "liquid", "glitch", "logo", "lower-third", "transition", "bg"],
20077
+ description: "Shelf it is filed under. Defaults to motion."
20078
+ },
20079
+ tags: { type: "array", items: { type: "string" }, description: "Optional search tags." },
20080
+ description: str("Optional one-line description.")
20081
+ }
20082
+ },
20083
+ method: "savePreset"
20084
+ },
20085
+ {
20086
+ name: "apply_preset",
20087
+ description: "Apply a preset (saved or built-in; ids from list_presets). With clipId, a one-clip preset is laid onto that clip, keeping its media and place and replacing animation only on the properties the preset sets. Without clipId, the preset\u2019s clips are added as new clips at time (default: the playhead). A preset with media layers (list_presets says needsClipId) needs clipId: its layers go on new tracks directly above that clip, sized to its box, starting with it.",
20088
+ inputSchema: {
20089
+ type: "object",
20090
+ properties: {
20091
+ presetId: str("Preset id from list_presets."),
20092
+ clipId: str("Optional clip to apply it to."),
20093
+ time: num("Where new clips start, in seconds, when clipId is not given. Defaults to the playhead."),
20094
+ trackId: str("Optional track for new clips.")
20095
+ },
20096
+ required: ["presetId"]
20097
+ },
20098
+ method: "applyPreset"
20099
+ },
20100
+ {
20101
+ name: "delete_preset",
20102
+ description: "Delete one of the user\u2019s saved presets. Built-in presets cannot be deleted.",
20103
+ inputSchema: {
20104
+ type: "object",
20105
+ properties: { presetId: str("Saved preset id from list_presets.") },
20106
+ required: ["presetId"]
20107
+ },
20108
+ method: "deletePreset"
20109
+ },
19783
20110
  {
19784
20111
  name: "set_text_animation",
19785
20112
  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.",
@@ -19824,7 +20151,7 @@ var TOOLS = [
19824
20151
  },
19825
20152
  {
19826
20153
  name: "import_local_file",
19827
- description: 'Import a media file from the user\'s own computer by FILENAME, through the local bridge. Use this when someone refers to a file they have rather than a URL \u2014 add_media_file cannot reach a path, because a browser tab cannot read one. The bridge searches only the folders its operator shared with --allow-dir, and only video, audio and image files inside them; anything else is not refused so much as invisible. Pass a bare filename ("interview-take-3.mp4"), not a path: the bridge does the finding, and a caller that cannot name a directory cannot be steered out of one. Requires the bridge to be running \u2014 `uvx goatedit-bridge --allow-dir <folder>` \u2014 with its token pasted into the editor.',
20154
+ description: 'Import a media file from the user\'s own computer by FILENAME, through the local bridge. Use this when someone refers to a file they have rather than a URL \u2014 add_media_file cannot reach a path, because a browser tab cannot read one. The bridge searches only the folders its operator shared with --allow-dir, and only video, audio and image files inside them; anything else is not refused so much as invisible. Pass a bare filename ("interview-take-3.mp4"), not a path: the bridge does the finding, and a caller that cannot name a directory cannot be steered out of one. Requires the bridge to be running \u2014 `uvx goatedit-bridge --allow-dir <folder>` \u2014 with its token pasted into the editor. The local goatedit-mcp server needs no bridge for files in its own assets folder: it finds them itself, and also takes a path relative to that folder as list_local_assets gives it.',
19828
20155
  inputSchema: {
19829
20156
  type: "object",
19830
20157
  properties: {
@@ -19982,7 +20309,7 @@ var TOOLS = [
19982
20309
  properties: {
19983
20310
  clipId: CLIP_ID,
19984
20311
  newStartTime: num("New timeline start time in seconds."),
19985
- newTrackId: str('Track to move onto. Defaults to the clip\u2019s current track. Accepts "__new_video__" to pick a video track free across the clip\u2019s span.')
20312
+ newTrackId: str('Track to move onto. Defaults to the clip\u2019s current track. Accepts "__new_video__" to pick a video track free across the clip\u2019s span, or "__new_track__" for a new video track on top.')
19986
20313
  },
19987
20314
  required: ["clipId", "newStartTime"]
19988
20315
  },
@@ -20106,6 +20433,90 @@ var TOOLS = [
20106
20433
  method: "redo"
20107
20434
  }
20108
20435
  ];
20436
+ var SEQUENCE_EDITS = /* @__PURE__ */ new Set([
20437
+ "addClip",
20438
+ "addClips",
20439
+ "addTextClip",
20440
+ "updateTextClip",
20441
+ "addShapeClip",
20442
+ "addAdjustmentLayer",
20443
+ "addTitleCard",
20444
+ "insertRipple",
20445
+ "setClipAudio",
20446
+ "setClipTransform",
20447
+ "setUltraKey",
20448
+ "setTrackMatte",
20449
+ "setShapeGradient",
20450
+ "setMask",
20451
+ "setKeyframes",
20452
+ "setClipEffect",
20453
+ "setTextAnimation",
20454
+ "addHtmlClip",
20455
+ "setColorGrade",
20456
+ "setClipDenoise",
20457
+ "addTransition",
20458
+ "applyAiEdit",
20459
+ "batchEdit",
20460
+ "clearTrack",
20461
+ "nestClips",
20462
+ "addSequenceClip",
20463
+ "removeClip",
20464
+ "moveClip",
20465
+ "trimClip",
20466
+ "splitClip",
20467
+ "rippleDeleteClip",
20468
+ "generateCaptions",
20469
+ "applyPreset",
20470
+ "updateShapeClip",
20471
+ "addNull",
20472
+ "setParent",
20473
+ "groupClips",
20474
+ "setProximity",
20475
+ "setClipSpeed",
20476
+ "setAutoLayout"
20477
+ ]);
20478
+ var EXPECTED_SEQUENCE = str(
20479
+ "Optional guard: the sequence id this edit is meant for (from list_sequences). If the tab has a different sequence open, the call is refused instead of editing the wrong one."
20480
+ );
20481
+ var PLACING_EDITS = /* @__PURE__ */ new Set([
20482
+ "addClip",
20483
+ "addClips",
20484
+ "addTextClip",
20485
+ "addShapeClip",
20486
+ "addAdjustmentLayer",
20487
+ "addHtmlClip",
20488
+ "addSequenceClip",
20489
+ "moveClip",
20490
+ "trimClip",
20491
+ "batchEdit",
20492
+ "generateCaptions",
20493
+ "addNull",
20494
+ "groupClips",
20495
+ "setClipSpeed"
20496
+ ]);
20497
+ var OVERWRITE = {
20498
+ type: "boolean",
20499
+ description: 'Placing, moving or lengthening a clip onto a span of a named track that other clips already hold is refused, naming them, because it would cut or delete them. Pass true to replace them anyway. Use trackId "__new_video__" for a free video track or "__new_track__" for a new one on top instead.'
20500
+ };
20501
+ var NEW_TRACK_ABOVE = str(
20502
+ 'With trackId "__new_track__": put the new track directly above this video track instead of on top of everything \u2014 for a layer that belongs with a clip, below a full-length adjustment layer.'
20503
+ );
20504
+ var NEW_TRACK_BELOW = str('With trackId "__new_track__": put the new track directly below this video track.');
20505
+ var TOOLS = BASE_TOOLS.map((tool) => {
20506
+ if (!tool.method || !SEQUENCE_EDITS.has(tool.method)) return tool;
20507
+ return {
20508
+ ...tool,
20509
+ inputSchema: {
20510
+ ...tool.inputSchema,
20511
+ properties: {
20512
+ ...tool.inputSchema.properties,
20513
+ expectedSequenceId: EXPECTED_SEQUENCE,
20514
+ ...PLACING_EDITS.has(tool.method) ? { overwrite: OVERWRITE } : {},
20515
+ ...PLACING_EDITS.has(tool.method) && tool.method !== "trimClip" ? { aboveTrackId: NEW_TRACK_ABOVE, belowTrackId: NEW_TRACK_BELOW } : {}
20516
+ }
20517
+ }
20518
+ };
20519
+ });
20109
20520
  var TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
20110
20521
  function editDistance(a, b) {
20111
20522
  const rows = [Array.from({ length: b.length + 1 }, (_, j) => j)];
@@ -20174,6 +20585,51 @@ function toContent(result) {
20174
20585
  return blocks;
20175
20586
  }
20176
20587
 
20588
+ // src/find-local-file.ts
20589
+ import fs from "fs";
20590
+ import path from "path";
20591
+ function findLocalFile(root, wanted, maxDepth) {
20592
+ const name = wanted.trim();
20593
+ if (!name || !fs.existsSync(root)) return null;
20594
+ if (name.includes("/") || name.includes(path.sep)) {
20595
+ const full = path.resolve(root, name);
20596
+ if (!full.startsWith(root + path.sep)) return null;
20597
+ try {
20598
+ return fs.statSync(full).isFile() ? full : null;
20599
+ } catch {
20600
+ return null;
20601
+ }
20602
+ }
20603
+ const target = name.toLowerCase();
20604
+ let level = [root];
20605
+ for (let depth = 0; depth <= maxDepth && level.length; depth++) {
20606
+ const hits = [];
20607
+ const next = [];
20608
+ for (const dir of level) {
20609
+ let entries;
20610
+ try {
20611
+ entries = fs.readdirSync(dir, { withFileTypes: true });
20612
+ } catch {
20613
+ continue;
20614
+ }
20615
+ for (const entry of entries) {
20616
+ if (entry.name.startsWith(".")) continue;
20617
+ const full = path.join(dir, entry.name);
20618
+ if (entry.isDirectory()) next.push(full);
20619
+ else if (entry.isFile() && entry.name.toLowerCase() === target) hits.push(full);
20620
+ }
20621
+ }
20622
+ if (hits.length > 1) {
20623
+ throw new Error(
20624
+ `Several files are named "${name}": ${hits.map((h) => path.relative(root, h)).sort().join(", ")}. Pass one of these paths as filename.`
20625
+ );
20626
+ }
20627
+ if (hits.length === 1) return hits[0];
20628
+ level = next;
20629
+ }
20630
+ return null;
20631
+ }
20632
+
20177
20633
  // src/index.ts
20178
20634
  var WEBSOCKET_PORT = 9e3;
20179
20635
  var BIND_HOST = "127.0.0.1";
@@ -20201,11 +20657,11 @@ function isLoopbackHost(host) {
20201
20657
  }
20202
20658
  }
20203
20659
  var CONTROLLER_TOKEN = process.env.GOATEDIT_CONTROLLER_TOKEN || crypto.randomBytes(32).toString("hex");
20204
- var CONTROLLER_TOKEN_FILE = path.join(os.homedir(), ".goatedit", "mcp-controller-token");
20660
+ var CONTROLLER_TOKEN_FILE = path2.join(os.homedir(), ".goatedit", "mcp-controller-token");
20205
20661
  function writeControllerToken() {
20206
- fs.mkdirSync(path.dirname(CONTROLLER_TOKEN_FILE), { recursive: true, mode: 448 });
20207
- fs.writeFileSync(CONTROLLER_TOKEN_FILE, CONTROLLER_TOKEN, { mode: 384 });
20208
- fs.chmodSync(CONTROLLER_TOKEN_FILE, 384);
20662
+ fs2.mkdirSync(path2.dirname(CONTROLLER_TOKEN_FILE), { recursive: true, mode: 448 });
20663
+ fs2.writeFileSync(CONTROLLER_TOKEN_FILE, CONTROLLER_TOKEN, { mode: 384 });
20664
+ fs2.chmodSync(CONTROLLER_TOKEN_FILE, 384);
20209
20665
  }
20210
20666
  function hasControllerToken(req) {
20211
20667
  const header = req.headers.authorization || "";
@@ -20215,24 +20671,24 @@ function hasControllerToken(req) {
20215
20671
  return a.length === b.length && crypto.timingSafeEqual(a, b);
20216
20672
  }
20217
20673
  var REQUEST_TIMEOUT_MS = 3e4;
20218
- var LOCAL_ASSETS_DIR = path.resolve(
20219
- process.env.GOATEDIT_ASSETS_DIR || path.join(os.homedir(), "Movies")
20674
+ var LOCAL_ASSETS_DIR = path2.resolve(
20675
+ process.env.GOATEDIT_ASSETS_DIR || path2.join(os.homedir(), "Movies")
20220
20676
  );
20221
- var FRAMES_DIR = path.resolve(
20222
- process.env.GOATEDIT_FRAMES_DIR || path.join(os.homedir(), ".goatedit", "frames")
20677
+ var FRAMES_DIR = path2.resolve(
20678
+ process.env.GOATEDIT_FRAMES_DIR || path2.join(os.homedir(), ".goatedit", "frames")
20223
20679
  );
20224
20680
  var VERSION = (() => {
20225
- const here = path.dirname(fileURLToPath(import.meta.url));
20681
+ const here = path2.dirname(fileURLToPath(import.meta.url));
20226
20682
  for (const rel of ["../package.json", "../../../package.json"]) {
20227
20683
  try {
20228
- const pkg = JSON.parse(fs.readFileSync(path.join(here, rel), "utf8"));
20684
+ const pkg = JSON.parse(fs2.readFileSync(path2.join(here, rel), "utf8"));
20229
20685
  if (pkg.name === "goatedit-mcp") return String(pkg.version);
20230
20686
  } catch {
20231
20687
  }
20232
20688
  }
20233
20689
  return "0.0.0";
20234
20690
  })();
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.`;
20691
+ var INSTRUCTIONS = `Edits a GoatEdit video project live in the user's browser tab at ai.goatedit.com. Call get_timeline before your first edit \u2014 clip ids change after every split, trim or delete, and a stale id is rejected. Each edit's reply carries timelineChanges (clips added, with their ids and placement; clips changed; ids removed), so take the next ids from there instead of reading the timeline again. 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
20692
  var MEDIA_TYPES = {
20237
20693
  ".mp4": { mime: "video/mp4", kind: "video" },
20238
20694
  ".m4v": { mime: "video/mp4", kind: "video" },
@@ -20253,18 +20709,18 @@ var MEDIA_TYPES = {
20253
20709
  ".json": { mime: "application/json", kind: "lottie" }
20254
20710
  };
20255
20711
  function mediaUrl(rel) {
20256
- return `http://localhost:${WEBSOCKET_PORT}/media/${rel.split(path.sep).map(encodeURIComponent).join("/")}`;
20712
+ return `http://localhost:${WEBSOCKET_PORT}/media/${rel.split(path2.sep).map(encodeURIComponent).join("/")}`;
20257
20713
  }
20258
20714
  var MAX_LISTED_ASSETS = 500;
20259
20715
  var MAX_ASSET_DEPTH = 4;
20260
20716
  async function handleListLocalAssets(args = {}) {
20261
- if (!fs.existsSync(LOCAL_ASSETS_DIR)) {
20717
+ if (!fs2.existsSync(LOCAL_ASSETS_DIR)) {
20262
20718
  throw new Error(
20263
20719
  `Assets directory does not exist: ${LOCAL_ASSETS_DIR}. Set GOATEDIT_ASSETS_DIR to a folder of media.`
20264
20720
  );
20265
20721
  }
20266
- const start = path.resolve(LOCAL_ASSETS_DIR, args.folder || "");
20267
- if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR + path.sep)) {
20722
+ const start = path2.resolve(LOCAL_ASSETS_DIR, args.folder || "");
20723
+ if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR + path2.sep)) {
20268
20724
  throw new Error("folder must be inside the assets directory.");
20269
20725
  }
20270
20726
  const files = [];
@@ -20272,7 +20728,7 @@ async function handleListLocalAssets(args = {}) {
20272
20728
  const walk = (dir, depth) => {
20273
20729
  let entries;
20274
20730
  try {
20275
- entries = fs.readdirSync(dir, { withFileTypes: true });
20731
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
20276
20732
  } catch {
20277
20733
  return;
20278
20734
  }
@@ -20283,17 +20739,17 @@ async function handleListLocalAssets(args = {}) {
20283
20739
  return;
20284
20740
  }
20285
20741
  if (entry.name.startsWith(".")) continue;
20286
- const full = path.join(dir, entry.name);
20742
+ const full = path2.join(dir, entry.name);
20287
20743
  if (entry.isDirectory()) {
20288
20744
  if (depth < MAX_ASSET_DEPTH) walk(full, depth + 1);
20289
20745
  continue;
20290
20746
  }
20291
- const type = MEDIA_TYPES[path.extname(entry.name).toLowerCase()];
20747
+ const type = MEDIA_TYPES[path2.extname(entry.name).toLowerCase()];
20292
20748
  if (!type || !entry.isFile()) continue;
20293
- const rel = path.relative(LOCAL_ASSETS_DIR, full);
20749
+ const rel = path2.relative(LOCAL_ASSETS_DIR, full);
20294
20750
  let bytes = 0;
20295
20751
  try {
20296
- bytes = fs.statSync(full).size;
20752
+ bytes = fs2.statSync(full).size;
20297
20753
  } catch {
20298
20754
  }
20299
20755
  files.push({ name: entry.name, path: rel, type: type.kind, bytes, url: mediaUrl(rel) });
@@ -20306,6 +20762,14 @@ async function handleListLocalAssets(args = {}) {
20306
20762
  ...truncated ? { truncated: true, note: `Stopped at ${MAX_LISTED_ASSETS} files. Pass folder to list one subfolder.` } : {}
20307
20763
  };
20308
20764
  }
20765
+ async function handleFindLocalFile(args = {}) {
20766
+ const hit = findLocalFile(LOCAL_ASSETS_DIR, String(args.filename ?? ""), MAX_ASSET_DEPTH);
20767
+ if (!hit) return null;
20768
+ const type = MEDIA_TYPES[path2.extname(hit).toLowerCase()];
20769
+ if (!type || type.kind === "lottie") return null;
20770
+ const rel = path2.relative(LOCAL_ASSETS_DIR, hit);
20771
+ return { name: path2.basename(hit), path: rel, url: mediaUrl(rel), contentType: type.mime };
20772
+ }
20309
20773
  function audioDuration(file) {
20310
20774
  try {
20311
20775
  const out = execFileSync("afinfo", [file], { encoding: "utf8" });
@@ -20323,10 +20787,10 @@ async function handleGenerateVoiceover(text, projectId) {
20323
20787
  throw new Error("generate_voiceover needs macOS `say`. Not available on this platform.");
20324
20788
  }
20325
20789
  const filename = `voiceover_${Date.now()}.wav`;
20326
- const filePath = path.join(LOCAL_ASSETS_DIR, filename);
20327
- fs.mkdirSync(LOCAL_ASSETS_DIR, { recursive: true });
20328
- const scriptPath = path.join(LOCAL_ASSETS_DIR, `.voiceover_${Date.now()}.txt`);
20329
- fs.writeFileSync(scriptPath, text, "utf8");
20790
+ const filePath = path2.join(LOCAL_ASSETS_DIR, filename);
20791
+ fs2.mkdirSync(LOCAL_ASSETS_DIR, { recursive: true });
20792
+ const scriptPath = path2.join(LOCAL_ASSETS_DIR, `.voiceover_${Date.now()}.txt`);
20793
+ fs2.writeFileSync(scriptPath, text, "utf8");
20330
20794
  try {
20331
20795
  execFileSync("say", ["-o", filePath, "-f", scriptPath], { stdio: "ignore" });
20332
20796
  const duration3 = audioDuration(filePath) ?? text.split(/\s+/).length * 0.45 + 1;
@@ -20344,7 +20808,7 @@ async function handleGenerateVoiceover(text, projectId) {
20344
20808
  throw new Error(`Failed to generate voiceover: ${err.message}`);
20345
20809
  } finally {
20346
20810
  try {
20347
- fs.unlinkSync(scriptPath);
20811
+ fs2.unlinkSync(scriptPath);
20348
20812
  } catch {
20349
20813
  }
20350
20814
  }
@@ -20353,14 +20817,14 @@ function saveFramesToDisk(result, label) {
20353
20817
  if (!result || typeof result !== "object") return result;
20354
20818
  const frames = [result._image, ...Array.isArray(result._images) ? result._images : []].filter((f) => f && typeof f.data === "string" && typeof f.mimeType === "string");
20355
20819
  if (!frames.length) return result;
20356
- fs.mkdirSync(FRAMES_DIR, { recursive: true });
20820
+ fs2.mkdirSync(FRAMES_DIR, { recursive: true });
20357
20821
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
20358
20822
  const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 60);
20359
20823
  const saved = frames.map((f, i) => {
20360
20824
  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}`);
20825
+ const file = path2.join(FRAMES_DIR, `${stamp}-${safe}${frames.length > 1 ? `-${i}` : ""}.${ext}`);
20362
20826
  const bytes = Buffer.from(f.data, "base64");
20363
- fs.writeFileSync(file, bytes);
20827
+ fs2.writeFileSync(file, bytes);
20364
20828
  return { path: file, bytes: bytes.length, ...f.caption ? { caption: f.caption } : {} };
20365
20829
  });
20366
20830
  const { _image, _images, ...rest } = result;
@@ -20371,8 +20835,8 @@ function saveFramesToDisk(result, label) {
20371
20835
  };
20372
20836
  }
20373
20837
  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";
20838
+ const size = fs2.statSync(filePath).size;
20839
+ const type = MEDIA_TYPES[path2.extname(filePath).toLowerCase()]?.mime ?? "application/octet-stream";
20376
20840
  const headers = { "Content-Type": type, "Accept-Ranges": "bytes" };
20377
20841
  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || "");
20378
20842
  if (range && (range[1] || range[2])) {
@@ -20395,7 +20859,7 @@ function serveFile(req, res, filePath) {
20395
20859
  res.end();
20396
20860
  return;
20397
20861
  }
20398
- fs.createReadStream(filePath, { start, end }).pipe(res);
20862
+ fs2.createReadStream(filePath, { start, end }).pipe(res);
20399
20863
  return;
20400
20864
  }
20401
20865
  res.writeHead(200, { ...headers, "Content-Length": size });
@@ -20403,7 +20867,7 @@ function serveFile(req, res, filePath) {
20403
20867
  res.end();
20404
20868
  return;
20405
20869
  }
20406
- fs.createReadStream(filePath).pipe(res);
20870
+ fs2.createReadStream(filePath).pipe(res);
20407
20871
  }
20408
20872
  var httpServer = http.createServer((req, res) => {
20409
20873
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -20441,9 +20905,9 @@ var httpServer = http.createServer((req, res) => {
20441
20905
  res.end("Bad path");
20442
20906
  return;
20443
20907
  }
20444
- const filePath = path.resolve(LOCAL_ASSETS_DIR, filename);
20445
- const inRoot = filePath === LOCAL_ASSETS_DIR || filePath.startsWith(LOCAL_ASSETS_DIR + path.sep);
20446
- if (inRoot && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
20908
+ const filePath = path2.resolve(LOCAL_ASSETS_DIR, filename);
20909
+ const inRoot = filePath === LOCAL_ASSETS_DIR || filePath.startsWith(LOCAL_ASSETS_DIR + path2.sep);
20910
+ if (inRoot && fs2.existsSync(filePath) && fs2.statSync(filePath).isFile()) {
20447
20911
  serveFile(req, res, filePath);
20448
20912
  } else {
20449
20913
  res.writeHead(404, { "Content-Type": "text/plain" });
@@ -20640,7 +21104,7 @@ function connectUpstream() {
20640
21104
  upstreamReady = new Promise((resolve, reject) => {
20641
21105
  let token;
20642
21106
  try {
20643
- token = process.env.GOATEDIT_CONTROLLER_TOKEN || fs.readFileSync(CONTROLLER_TOKEN_FILE, "utf8").trim();
21107
+ token = process.env.GOATEDIT_CONTROLLER_TOKEN || fs2.readFileSync(CONTROLLER_TOKEN_FILE, "utf8").trim();
20644
21108
  } catch {
20645
21109
  reject(new Error(notConnectedMessage()));
20646
21110
  return;
@@ -20712,7 +21176,8 @@ async function sendToBrowser(method, params = {}, projectId) {
20712
21176
  var LOCAL_HANDLERS = {
20713
21177
  list_connected_projects: async () => connectedProjects(),
20714
21178
  list_local_assets: async (args) => handleListLocalAssets(args),
20715
- generate_voiceover: async (args, projectId) => handleGenerateVoiceover(args.text, projectId)
21179
+ generate_voiceover: async (args, projectId) => handleGenerateVoiceover(args.text, projectId),
21180
+ find_local_file: async (args) => handleFindLocalFile(args)
20716
21181
  };
20717
21182
  async function callLocal(method, args, projectId) {
20718
21183
  await ensureTransport();
@@ -20821,9 +21286,26 @@ async function callTool(name, args) {
20821
21286
  const invalid = validateArgs(tool, forwarded);
20822
21287
  if (invalid) throw new McpError(ErrorCode.InvalidParams, invalid);
20823
21288
  const target = projectId ?? chosenProjectId;
21289
+ if (name === "import_local_file") return importLocalFile(forwarded, target);
20824
21290
  const result = await sendToBrowser(tool.method, forwarded, target);
20825
21291
  return saveToDisk ? saveFramesToDisk(result, name) : result;
20826
21292
  }
21293
+ async function importLocalFile(args, target) {
21294
+ const local = await callLocal("find_local_file", { filename: args.filename }, target).catch((err) => {
21295
+ if (/^Several files/.test(err?.message ?? "")) throw err;
21296
+ return null;
21297
+ });
21298
+ if (local) {
21299
+ return sendToBrowser("importLocalFile", { ...args, localUrl: local.url, contentType: local.contentType, name: local.name }, target);
21300
+ }
21301
+ try {
21302
+ return await sendToBrowser("importLocalFile", args, target);
21303
+ } catch (err) {
21304
+ throw new Error(
21305
+ `${err?.message ?? err} It is not in this server's assets directory (${LOCAL_ASSETS_DIR}) either: put it there, or start this server with GOATEDIT_ASSETS_DIR set to its folder. list_local_assets shows what is there.`
21306
+ );
21307
+ }
21308
+ }
20827
21309
  async function runTools(args) {
20828
21310
  const calls = args.calls;
20829
21311
  if (!Array.isArray(calls) || !calls.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goatedit-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Local MCP server for the GoatEdit video editor: drive your open editor tab from Claude Code or any MCP client",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",