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 +4 -0
- package/bundle/goatedit-mcp.js +189 -57
- package/package.json +1 -1
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
|
package/bundle/goatedit-mcp.js
CHANGED
|
@@ -3232,8 +3232,8 @@ var require_utils = __commonJS({
|
|
|
3232
3232
|
}
|
|
3233
3233
|
return ind;
|
|
3234
3234
|
}
|
|
3235
|
-
function removeDotSegments(
|
|
3236
|
-
let input =
|
|
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 [
|
|
3489
|
-
wsComponent.path =
|
|
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,
|
|
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,
|
|
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,
|
|
10815
|
-
if (!
|
|
10814
|
+
function getElementAtPath(obj, path3) {
|
|
10815
|
+
if (!path3)
|
|
10816
10816
|
return obj;
|
|
10817
|
-
return
|
|
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(
|
|
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(
|
|
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,
|
|
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 }, [...
|
|
11380
|
+
issue2.errors.map((issues) => processError({ issues }, [...path3, ...issue2.path]));
|
|
11381
11381
|
} else if (issue2.code === "invalid_key") {
|
|
11382
|
-
processError({ issues: issue2.issues }, [...
|
|
11382
|
+
processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
|
|
11383
11383
|
} else if (issue2.code === "invalid_element") {
|
|
11384
|
-
processError({ issues: issue2.issues }, [...
|
|
11384
|
+
processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
|
|
11385
11385
|
} else {
|
|
11386
|
-
const fullpath = [...
|
|
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
|
|
19148
|
-
import
|
|
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
|
|
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 =
|
|
20310
|
+
var CONTROLLER_TOKEN_FILE = path2.join(os.homedir(), ".goatedit", "mcp-controller-token");
|
|
20205
20311
|
function writeControllerToken() {
|
|
20206
|
-
|
|
20207
|
-
|
|
20208
|
-
|
|
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 =
|
|
20219
|
-
process.env.GOATEDIT_ASSETS_DIR ||
|
|
20324
|
+
var LOCAL_ASSETS_DIR = path2.resolve(
|
|
20325
|
+
process.env.GOATEDIT_ASSETS_DIR || path2.join(os.homedir(), "Movies")
|
|
20220
20326
|
);
|
|
20221
|
-
var FRAMES_DIR =
|
|
20222
|
-
process.env.GOATEDIT_FRAMES_DIR ||
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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 (!
|
|
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 =
|
|
20267
|
-
if (start !== LOCAL_ASSETS_DIR && !start.startsWith(LOCAL_ASSETS_DIR +
|
|
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 =
|
|
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 =
|
|
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[
|
|
20397
|
+
const type = MEDIA_TYPES[path2.extname(entry.name).toLowerCase()];
|
|
20292
20398
|
if (!type || !entry.isFile()) continue;
|
|
20293
|
-
const rel =
|
|
20399
|
+
const rel = path2.relative(LOCAL_ASSETS_DIR, full);
|
|
20294
20400
|
let bytes = 0;
|
|
20295
20401
|
try {
|
|
20296
|
-
bytes =
|
|
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 =
|
|
20327
|
-
|
|
20328
|
-
const scriptPath =
|
|
20329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
20475
|
+
const file = path2.join(FRAMES_DIR, `${stamp}-${safe}${frames.length > 1 ? `-${i}` : ""}.${ext}`);
|
|
20362
20476
|
const bytes = Buffer.from(f.data, "base64");
|
|
20363
|
-
|
|
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 =
|
|
20375
|
-
const type = MEDIA_TYPES[
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
20445
|
-
const inRoot = filePath === LOCAL_ASSETS_DIR || filePath.startsWith(LOCAL_ASSETS_DIR +
|
|
20446
|
-
if (inRoot &&
|
|
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 ||
|
|
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