goatedit-mcp 1.1.0 → 1.1.1

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";
@@ -19356,7 +19356,7 @@ var TOOLS = [
19356
19356
  },
19357
19357
  {
19358
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.',
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 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. 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
19360
  inputSchema: {
19361
19361
  type: "object",
19362
19362
  properties: {
@@ -19780,6 +19780,67 @@ var TOOLS = [
19780
19780
  },
19781
19781
  method: "describeEffect"
19782
19782
  },
19783
+ {
19784
+ name: "list_presets",
19785
+ 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.",
19786
+ inputSchema: {
19787
+ type: "object",
19788
+ properties: {
19789
+ category: {
19790
+ type: "string",
19791
+ enum: ["text", "motion", "paper", "liquid", "glitch", "logo", "lower-third", "transition", "bg"],
19792
+ description: "List the built-in presets of one category."
19793
+ },
19794
+ query: str("Search built-in presets by name, description or tag.")
19795
+ }
19796
+ },
19797
+ method: "listPresets"
19798
+ },
19799
+ {
19800
+ name: "save_preset",
19801
+ 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.",
19802
+ inputSchema: {
19803
+ type: "object",
19804
+ properties: {
19805
+ clipIds: { type: "array", items: { type: "string" }, description: "The clips to capture." },
19806
+ name: str("Preset name."),
19807
+ category: {
19808
+ type: "string",
19809
+ enum: ["text", "motion", "paper", "liquid", "glitch", "logo", "lower-third", "transition", "bg"],
19810
+ description: "Shelf it is filed under. Defaults to motion."
19811
+ },
19812
+ tags: { type: "array", items: { type: "string" }, description: "Optional search tags." },
19813
+ description: str("Optional one-line description.")
19814
+ },
19815
+ required: ["clipIds", "name"]
19816
+ },
19817
+ method: "savePreset"
19818
+ },
19819
+ {
19820
+ name: "apply_preset",
19821
+ 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).",
19822
+ inputSchema: {
19823
+ type: "object",
19824
+ properties: {
19825
+ presetId: str("Preset id from list_presets."),
19826
+ clipId: str("Optional clip to apply it to."),
19827
+ time: num("Where new clips start, in seconds, when clipId is not given. Defaults to the playhead."),
19828
+ trackId: str("Optional track for new clips.")
19829
+ },
19830
+ required: ["presetId"]
19831
+ },
19832
+ method: "applyPreset"
19833
+ },
19834
+ {
19835
+ name: "delete_preset",
19836
+ description: "Delete one of the user\u2019s saved presets. Built-in presets cannot be deleted.",
19837
+ inputSchema: {
19838
+ type: "object",
19839
+ properties: { presetId: str("Saved preset id from list_presets.") },
19840
+ required: ["presetId"]
19841
+ },
19842
+ method: "deletePreset"
19843
+ },
19783
19844
  {
19784
19845
  name: "set_text_animation",
19785
19846
  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 +19885,7 @@ var TOOLS = [
19824
19885
  },
19825
19886
  {
19826
19887
  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.',
19888
+ 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
19889
  inputSchema: {
19829
19890
  type: "object",
19830
19891
  properties: {
@@ -20174,6 +20235,51 @@ function toContent(result) {
20174
20235
  return blocks;
20175
20236
  }
20176
20237
 
20238
+ // src/find-local-file.ts
20239
+ import fs from "fs";
20240
+ import path from "path";
20241
+ function findLocalFile(root, wanted, maxDepth) {
20242
+ const name = wanted.trim();
20243
+ if (!name || !fs.existsSync(root)) return null;
20244
+ if (name.includes("/") || name.includes(path.sep)) {
20245
+ const full = path.resolve(root, name);
20246
+ if (!full.startsWith(root + path.sep)) return null;
20247
+ try {
20248
+ return fs.statSync(full).isFile() ? full : null;
20249
+ } catch {
20250
+ return null;
20251
+ }
20252
+ }
20253
+ const target = name.toLowerCase();
20254
+ let level = [root];
20255
+ for (let depth = 0; depth <= maxDepth && level.length; depth++) {
20256
+ const hits = [];
20257
+ const next = [];
20258
+ for (const dir of level) {
20259
+ let entries;
20260
+ try {
20261
+ entries = fs.readdirSync(dir, { withFileTypes: true });
20262
+ } catch {
20263
+ continue;
20264
+ }
20265
+ for (const entry of entries) {
20266
+ if (entry.name.startsWith(".")) continue;
20267
+ const full = path.join(dir, entry.name);
20268
+ if (entry.isDirectory()) next.push(full);
20269
+ else if (entry.isFile() && entry.name.toLowerCase() === target) hits.push(full);
20270
+ }
20271
+ }
20272
+ if (hits.length > 1) {
20273
+ throw new Error(
20274
+ `Several files are named "${name}": ${hits.map((h) => path.relative(root, h)).sort().join(", ")}. Pass one of these paths as filename.`
20275
+ );
20276
+ }
20277
+ if (hits.length === 1) return hits[0];
20278
+ level = next;
20279
+ }
20280
+ return null;
20281
+ }
20282
+
20177
20283
  // src/index.ts
20178
20284
  var WEBSOCKET_PORT = 9e3;
20179
20285
  var BIND_HOST = "127.0.0.1";
@@ -20201,11 +20307,11 @@ function isLoopbackHost(host) {
20201
20307
  }
20202
20308
  }
20203
20309
  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");
20310
+ var CONTROLLER_TOKEN_FILE = path2.join(os.homedir(), ".goatedit", "mcp-controller-token");
20205
20311
  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);
20312
+ fs2.mkdirSync(path2.dirname(CONTROLLER_TOKEN_FILE), { recursive: true, mode: 448 });
20313
+ fs2.writeFileSync(CONTROLLER_TOKEN_FILE, CONTROLLER_TOKEN, { mode: 384 });
20314
+ fs2.chmodSync(CONTROLLER_TOKEN_FILE, 384);
20209
20315
  }
20210
20316
  function hasControllerToken(req) {
20211
20317
  const header = req.headers.authorization || "";
@@ -20215,24 +20321,24 @@ function hasControllerToken(req) {
20215
20321
  return a.length === b.length && crypto.timingSafeEqual(a, b);
20216
20322
  }
20217
20323
  var REQUEST_TIMEOUT_MS = 3e4;
20218
- var LOCAL_ASSETS_DIR = path.resolve(
20219
- process.env.GOATEDIT_ASSETS_DIR || path.join(os.homedir(), "Movies")
20324
+ var LOCAL_ASSETS_DIR = path2.resolve(
20325
+ process.env.GOATEDIT_ASSETS_DIR || path2.join(os.homedir(), "Movies")
20220
20326
  );
20221
- var FRAMES_DIR = path.resolve(
20222
- process.env.GOATEDIT_FRAMES_DIR || path.join(os.homedir(), ".goatedit", "frames")
20327
+ var FRAMES_DIR = path2.resolve(
20328
+ process.env.GOATEDIT_FRAMES_DIR || path2.join(os.homedir(), ".goatedit", "frames")
20223
20329
  );
20224
20330
  var VERSION = (() => {
20225
- const here = path.dirname(fileURLToPath(import.meta.url));
20331
+ const here = path2.dirname(fileURLToPath(import.meta.url));
20226
20332
  for (const rel of ["../package.json", "../../../package.json"]) {
20227
20333
  try {
20228
- const pkg = JSON.parse(fs.readFileSync(path.join(here, rel), "utf8"));
20334
+ const pkg = JSON.parse(fs2.readFileSync(path2.join(here, rel), "utf8"));
20229
20335
  if (pkg.name === "goatedit-mcp") return String(pkg.version);
20230
20336
  } catch {
20231
20337
  }
20232
20338
  }
20233
20339
  return "0.0.0";
20234
20340
  })();
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.`;
20341
+ 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
20342
  var MEDIA_TYPES = {
20237
20343
  ".mp4": { mime: "video/mp4", kind: "video" },
20238
20344
  ".m4v": { mime: "video/mp4", kind: "video" },
@@ -20253,18 +20359,18 @@ var MEDIA_TYPES = {
20253
20359
  ".json": { mime: "application/json", kind: "lottie" }
20254
20360
  };
20255
20361
  function mediaUrl(rel) {
20256
- return `http://localhost:${WEBSOCKET_PORT}/media/${rel.split(path.sep).map(encodeURIComponent).join("/")}`;
20362
+ return `http://localhost:${WEBSOCKET_PORT}/media/${rel.split(path2.sep).map(encodeURIComponent).join("/")}`;
20257
20363
  }
20258
20364
  var MAX_LISTED_ASSETS = 500;
20259
20365
  var MAX_ASSET_DEPTH = 4;
20260
20366
  async function handleListLocalAssets(args = {}) {
20261
- if (!fs.existsSync(LOCAL_ASSETS_DIR)) {
20367
+ if (!fs2.existsSync(LOCAL_ASSETS_DIR)) {
20262
20368
  throw new Error(
20263
20369
  `Assets directory does not exist: ${LOCAL_ASSETS_DIR}. Set GOATEDIT_ASSETS_DIR to a folder of media.`
20264
20370
  );
20265
20371
  }
20266
- const start = path.resolve(LOCAL_ASSETS_DIR, args.folder || "");
20267
- if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR + path.sep)) {
20372
+ const start = path2.resolve(LOCAL_ASSETS_DIR, args.folder || "");
20373
+ if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR + path2.sep)) {
20268
20374
  throw new Error("folder must be inside the assets directory.");
20269
20375
  }
20270
20376
  const files = [];
@@ -20272,7 +20378,7 @@ async function handleListLocalAssets(args = {}) {
20272
20378
  const walk = (dir, depth) => {
20273
20379
  let entries;
20274
20380
  try {
20275
- entries = fs.readdirSync(dir, { withFileTypes: true });
20381
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
20276
20382
  } catch {
20277
20383
  return;
20278
20384
  }
@@ -20283,17 +20389,17 @@ async function handleListLocalAssets(args = {}) {
20283
20389
  return;
20284
20390
  }
20285
20391
  if (entry.name.startsWith(".")) continue;
20286
- const full = path.join(dir, entry.name);
20392
+ const full = path2.join(dir, entry.name);
20287
20393
  if (entry.isDirectory()) {
20288
20394
  if (depth < MAX_ASSET_DEPTH) walk(full, depth + 1);
20289
20395
  continue;
20290
20396
  }
20291
- const type = MEDIA_TYPES[path.extname(entry.name).toLowerCase()];
20397
+ const type = MEDIA_TYPES[path2.extname(entry.name).toLowerCase()];
20292
20398
  if (!type || !entry.isFile()) continue;
20293
- const rel = path.relative(LOCAL_ASSETS_DIR, full);
20399
+ const rel = path2.relative(LOCAL_ASSETS_DIR, full);
20294
20400
  let bytes = 0;
20295
20401
  try {
20296
- bytes = fs.statSync(full).size;
20402
+ bytes = fs2.statSync(full).size;
20297
20403
  } catch {
20298
20404
  }
20299
20405
  files.push({ name: entry.name, path: rel, type: type.kind, bytes, url: mediaUrl(rel) });
@@ -20306,6 +20412,14 @@ async function handleListLocalAssets(args = {}) {
20306
20412
  ...truncated ? { truncated: true, note: `Stopped at ${MAX_LISTED_ASSETS} files. Pass folder to list one subfolder.` } : {}
20307
20413
  };
20308
20414
  }
20415
+ async function handleFindLocalFile(args = {}) {
20416
+ const hit = findLocalFile(LOCAL_ASSETS_DIR, String(args.filename ?? ""), MAX_ASSET_DEPTH);
20417
+ if (!hit) return null;
20418
+ const type = MEDIA_TYPES[path2.extname(hit).toLowerCase()];
20419
+ if (!type || type.kind === "lottie") return null;
20420
+ const rel = path2.relative(LOCAL_ASSETS_DIR, hit);
20421
+ return { name: path2.basename(hit), path: rel, url: mediaUrl(rel), contentType: type.mime };
20422
+ }
20309
20423
  function audioDuration(file) {
20310
20424
  try {
20311
20425
  const out = execFileSync("afinfo", [file], { encoding: "utf8" });
@@ -20323,10 +20437,10 @@ async function handleGenerateVoiceover(text, projectId) {
20323
20437
  throw new Error("generate_voiceover needs macOS `say`. Not available on this platform.");
20324
20438
  }
20325
20439
  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");
20440
+ const filePath = path2.join(LOCAL_ASSETS_DIR, filename);
20441
+ fs2.mkdirSync(LOCAL_ASSETS_DIR, { recursive: true });
20442
+ const scriptPath = path2.join(LOCAL_ASSETS_DIR, `.voiceover_${Date.now()}.txt`);
20443
+ fs2.writeFileSync(scriptPath, text, "utf8");
20330
20444
  try {
20331
20445
  execFileSync("say", ["-o", filePath, "-f", scriptPath], { stdio: "ignore" });
20332
20446
  const duration3 = audioDuration(filePath) ?? text.split(/\s+/).length * 0.45 + 1;
@@ -20344,7 +20458,7 @@ async function handleGenerateVoiceover(text, projectId) {
20344
20458
  throw new Error(`Failed to generate voiceover: ${err.message}`);
20345
20459
  } finally {
20346
20460
  try {
20347
- fs.unlinkSync(scriptPath);
20461
+ fs2.unlinkSync(scriptPath);
20348
20462
  } catch {
20349
20463
  }
20350
20464
  }
@@ -20353,14 +20467,14 @@ function saveFramesToDisk(result, label) {
20353
20467
  if (!result || typeof result !== "object") return result;
20354
20468
  const frames = [result._image, ...Array.isArray(result._images) ? result._images : []].filter((f) => f && typeof f.data === "string" && typeof f.mimeType === "string");
20355
20469
  if (!frames.length) return result;
20356
- fs.mkdirSync(FRAMES_DIR, { recursive: true });
20470
+ fs2.mkdirSync(FRAMES_DIR, { recursive: true });
20357
20471
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
20358
20472
  const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 60);
20359
20473
  const saved = frames.map((f, i) => {
20360
20474
  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}`);
20475
+ const file = path2.join(FRAMES_DIR, `${stamp}-${safe}${frames.length > 1 ? `-${i}` : ""}.${ext}`);
20362
20476
  const bytes = Buffer.from(f.data, "base64");
20363
- fs.writeFileSync(file, bytes);
20477
+ fs2.writeFileSync(file, bytes);
20364
20478
  return { path: file, bytes: bytes.length, ...f.caption ? { caption: f.caption } : {} };
20365
20479
  });
20366
20480
  const { _image, _images, ...rest } = result;
@@ -20371,8 +20485,8 @@ function saveFramesToDisk(result, label) {
20371
20485
  };
20372
20486
  }
20373
20487
  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";
20488
+ const size = fs2.statSync(filePath).size;
20489
+ const type = MEDIA_TYPES[path2.extname(filePath).toLowerCase()]?.mime ?? "application/octet-stream";
20376
20490
  const headers = { "Content-Type": type, "Accept-Ranges": "bytes" };
20377
20491
  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || "");
20378
20492
  if (range && (range[1] || range[2])) {
@@ -20395,7 +20509,7 @@ function serveFile(req, res, filePath) {
20395
20509
  res.end();
20396
20510
  return;
20397
20511
  }
20398
- fs.createReadStream(filePath, { start, end }).pipe(res);
20512
+ fs2.createReadStream(filePath, { start, end }).pipe(res);
20399
20513
  return;
20400
20514
  }
20401
20515
  res.writeHead(200, { ...headers, "Content-Length": size });
@@ -20403,7 +20517,7 @@ function serveFile(req, res, filePath) {
20403
20517
  res.end();
20404
20518
  return;
20405
20519
  }
20406
- fs.createReadStream(filePath).pipe(res);
20520
+ fs2.createReadStream(filePath).pipe(res);
20407
20521
  }
20408
20522
  var httpServer = http.createServer((req, res) => {
20409
20523
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
@@ -20441,9 +20555,9 @@ var httpServer = http.createServer((req, res) => {
20441
20555
  res.end("Bad path");
20442
20556
  return;
20443
20557
  }
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()) {
20558
+ const filePath = path2.resolve(LOCAL_ASSETS_DIR, filename);
20559
+ const inRoot = filePath === LOCAL_ASSETS_DIR || filePath.startsWith(LOCAL_ASSETS_DIR + path2.sep);
20560
+ if (inRoot && fs2.existsSync(filePath) && fs2.statSync(filePath).isFile()) {
20447
20561
  serveFile(req, res, filePath);
20448
20562
  } else {
20449
20563
  res.writeHead(404, { "Content-Type": "text/plain" });
@@ -20640,7 +20754,7 @@ function connectUpstream() {
20640
20754
  upstreamReady = new Promise((resolve, reject) => {
20641
20755
  let token;
20642
20756
  try {
20643
- token = process.env.GOATEDIT_CONTROLLER_TOKEN || fs.readFileSync(CONTROLLER_TOKEN_FILE, "utf8").trim();
20757
+ token = process.env.GOATEDIT_CONTROLLER_TOKEN || fs2.readFileSync(CONTROLLER_TOKEN_FILE, "utf8").trim();
20644
20758
  } catch {
20645
20759
  reject(new Error(notConnectedMessage()));
20646
20760
  return;
@@ -20712,7 +20826,8 @@ async function sendToBrowser(method, params = {}, projectId) {
20712
20826
  var LOCAL_HANDLERS = {
20713
20827
  list_connected_projects: async () => connectedProjects(),
20714
20828
  list_local_assets: async (args) => handleListLocalAssets(args),
20715
- generate_voiceover: async (args, projectId) => handleGenerateVoiceover(args.text, projectId)
20829
+ generate_voiceover: async (args, projectId) => handleGenerateVoiceover(args.text, projectId),
20830
+ find_local_file: async (args) => handleFindLocalFile(args)
20716
20831
  };
20717
20832
  async function callLocal(method, args, projectId) {
20718
20833
  await ensureTransport();
@@ -20821,9 +20936,26 @@ async function callTool(name, args) {
20821
20936
  const invalid = validateArgs(tool, forwarded);
20822
20937
  if (invalid) throw new McpError(ErrorCode.InvalidParams, invalid);
20823
20938
  const target = projectId ?? chosenProjectId;
20939
+ if (name === "import_local_file") return importLocalFile(forwarded, target);
20824
20940
  const result = await sendToBrowser(tool.method, forwarded, target);
20825
20941
  return saveToDisk ? saveFramesToDisk(result, name) : result;
20826
20942
  }
20943
+ async function importLocalFile(args, target) {
20944
+ const local = await callLocal("find_local_file", { filename: args.filename }, target).catch((err) => {
20945
+ if (/^Several files/.test(err?.message ?? "")) throw err;
20946
+ return null;
20947
+ });
20948
+ if (local) {
20949
+ return sendToBrowser("importLocalFile", { ...args, localUrl: local.url, contentType: local.contentType, name: local.name }, target);
20950
+ }
20951
+ try {
20952
+ return await sendToBrowser("importLocalFile", args, target);
20953
+ } catch (err) {
20954
+ throw new Error(
20955
+ `${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.`
20956
+ );
20957
+ }
20958
+ }
20827
20959
  async function runTools(args) {
20828
20960
  const calls = args.calls;
20829
20961
  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.1",
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",