@marlinjai/studio-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli.js +3306 -0
- package/dist/index.d.ts +2443 -0
- package/dist/index.js +3352 -0
- package/examples/claude-code.sh +17 -0
- package/examples/cursor-mcp.json +12 -0
- package/examples/smoke-http.mjs +72 -0
- package/examples/workflow-character-sheet.json +205 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3352 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var StudioApiError = class extends Error {
|
|
6
|
+
status;
|
|
7
|
+
method;
|
|
8
|
+
path;
|
|
9
|
+
body;
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
super(`${opts.method} ${opts.path} -> ${opts.status}: ${opts.message}`);
|
|
12
|
+
this.name = "StudioApiError";
|
|
13
|
+
this.status = opts.status;
|
|
14
|
+
this.method = opts.method;
|
|
15
|
+
this.path = opts.path;
|
|
16
|
+
this.body = opts.body;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var PollTimeoutError = class extends Error {
|
|
20
|
+
lastState;
|
|
21
|
+
constructor(what, timeoutMs, lastState) {
|
|
22
|
+
super(
|
|
23
|
+
`Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for ${what}. The work is still running server-side; poll it again with the returned id.`
|
|
24
|
+
);
|
|
25
|
+
this.name = "PollTimeoutError";
|
|
26
|
+
this.lastState = lastState;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var ToolInputError = class extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "ToolInputError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function redactSecret(text, secret) {
|
|
36
|
+
if (!secret || secret.length === 0) return text;
|
|
37
|
+
return text.split(secret).join("[REDACTED]");
|
|
38
|
+
}
|
|
39
|
+
function apiMessageFromBody(body, fallback) {
|
|
40
|
+
if (body && typeof body === "object") {
|
|
41
|
+
const o = body;
|
|
42
|
+
if (typeof o.error === "string" && o.error.length > 0) {
|
|
43
|
+
const detail = typeof o.message === "string" ? o.message : "";
|
|
44
|
+
const adds = detail.length > 0 && !o.error.includes(detail) && !detail.includes(o.error);
|
|
45
|
+
const headline = adds ? `${o.error}: ${detail}` : o.error;
|
|
46
|
+
const quota = quotaDetail(o);
|
|
47
|
+
return quota ? `${headline} (${quota})` : headline;
|
|
48
|
+
}
|
|
49
|
+
const code = typeof o.code === "string" && o.code.length > 0 ? o.code : null;
|
|
50
|
+
if (typeof o.message === "string" && o.message.length > 0) return code ? `${code}: ${o.message}` : o.message;
|
|
51
|
+
if (code) {
|
|
52
|
+
const issues = Array.isArray(o.issues) ? o.issues.map(formatIssue).filter((s) => s !== null) : [];
|
|
53
|
+
return issues.length > 0 ? `${code}: ${issues.join("; ")}` : code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (typeof body === "string" && body.trim().length > 0) return body.trim().slice(0, 500);
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
function quotaDetail(body) {
|
|
60
|
+
if (typeof body.capUsd === "number" && typeof body.usedUsd === "number") {
|
|
61
|
+
const resetsAt = typeof body.resetsAt === "string" ? `, resets ${body.resetsAt}` : "";
|
|
62
|
+
return `cap $${body.capUsd.toFixed(2)}, used $${body.usedUsd.toFixed(2)}${resetsAt}`;
|
|
63
|
+
}
|
|
64
|
+
if (typeof body.rpm === "number") {
|
|
65
|
+
return `limit ${body.rpm} req/min, see the response's Retry-After header for when to retry`;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function formatIssue(issue) {
|
|
70
|
+
if (!issue || typeof issue !== "object") return null;
|
|
71
|
+
const o = issue;
|
|
72
|
+
if (typeof o.message !== "string") return null;
|
|
73
|
+
const path = Array.isArray(o.path) ? o.path.map(String).join(".") : "";
|
|
74
|
+
return `${path || "body"}: ${o.message}`;
|
|
75
|
+
}
|
|
76
|
+
function errorMessage(err) {
|
|
77
|
+
if (err instanceof Error) return err.message;
|
|
78
|
+
return String(err);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/client.ts
|
|
82
|
+
var StudioClient = class {
|
|
83
|
+
baseUrl;
|
|
84
|
+
// Native private fields: not enumerable, never serialized, unreachable from outside.
|
|
85
|
+
#apiKey;
|
|
86
|
+
#fetchImpl;
|
|
87
|
+
#requestTimeoutMs;
|
|
88
|
+
#userAgent;
|
|
89
|
+
constructor(config, opts = {}) {
|
|
90
|
+
this.baseUrl = config.baseUrl;
|
|
91
|
+
this.#apiKey = config.apiKey;
|
|
92
|
+
this.#fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
|
|
93
|
+
this.#requestTimeoutMs = opts.requestTimeoutMs ?? 6e4;
|
|
94
|
+
this.#userAgent = opts.userAgent ?? "studio-mcp";
|
|
95
|
+
}
|
|
96
|
+
/** Strip the bearer token from any string before it can reach a caller. */
|
|
97
|
+
redact(text) {
|
|
98
|
+
return redactSecret(text, this.#apiKey);
|
|
99
|
+
}
|
|
100
|
+
// ── Projects ──
|
|
101
|
+
listProjects() {
|
|
102
|
+
return this.json("GET", "/api/v1/projects");
|
|
103
|
+
}
|
|
104
|
+
// ── Generation jobs ──
|
|
105
|
+
generate(body) {
|
|
106
|
+
return this.json("POST", "/api/generate", { json: body });
|
|
107
|
+
}
|
|
108
|
+
getJob(jobId) {
|
|
109
|
+
return this.json("GET", `/api/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
110
|
+
}
|
|
111
|
+
// ── Characters ──
|
|
112
|
+
listCharacters() {
|
|
113
|
+
return this.json("GET", "/api/characters");
|
|
114
|
+
}
|
|
115
|
+
getCharacter(slug2) {
|
|
116
|
+
return this.json("GET", `/api/characters/${encodeURIComponent(slug2)}`);
|
|
117
|
+
}
|
|
118
|
+
createCharacter(body) {
|
|
119
|
+
return this.json("POST", "/api/characters", { json: body });
|
|
120
|
+
}
|
|
121
|
+
uploadCharacterReference(slug2, input) {
|
|
122
|
+
const form = new FormData();
|
|
123
|
+
form.append("file", toFile(input.file));
|
|
124
|
+
form.append("category", input.category);
|
|
125
|
+
if (input.label) form.append("label", input.label);
|
|
126
|
+
return this.json("POST", `/api/characters/${encodeURIComponent(slug2)}/references/upload`, { form });
|
|
127
|
+
}
|
|
128
|
+
// ── Brands ──
|
|
129
|
+
createBrand(body) {
|
|
130
|
+
return this.json("POST", "/api/brands", { json: body });
|
|
131
|
+
}
|
|
132
|
+
uploadBrandReference(slug2, input) {
|
|
133
|
+
const form = new FormData();
|
|
134
|
+
form.append("file", toFile(input.file));
|
|
135
|
+
form.append("category", input.category ?? "general");
|
|
136
|
+
if (input.label) form.append("name", input.label);
|
|
137
|
+
if (input.tags && input.tags.length > 0) form.append("tags", input.tags.join(","));
|
|
138
|
+
if (input.score !== void 0) form.append("score", String(input.score));
|
|
139
|
+
return this.json("POST", `/api/brands/${encodeURIComponent(slug2)}/library/upload`, { form });
|
|
140
|
+
}
|
|
141
|
+
// ── Workflows ──
|
|
142
|
+
runWorkflow(body) {
|
|
143
|
+
return this.json("POST", "/api/v1/workflows/run", { json: body });
|
|
144
|
+
}
|
|
145
|
+
getRun(runId) {
|
|
146
|
+
return this.json("GET", `/api/v1/workflows/runs/${encodeURIComponent(runId)}`);
|
|
147
|
+
}
|
|
148
|
+
/** Submit a human decision on a parked judge node (P3 J2). Approving
|
|
149
|
+
* resumes the run from that node; rejecting fails it and stops the branch. */
|
|
150
|
+
decideJudgeNode(runId, nodeId, body) {
|
|
151
|
+
return this.json(
|
|
152
|
+
"POST",
|
|
153
|
+
`/api/v1/workflows/runs/${encodeURIComponent(runId)}/node/${encodeURIComponent(nodeId)}/decision`,
|
|
154
|
+
{ json: body }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
getRunNodeUrl(runId, nodeId) {
|
|
158
|
+
return this.json(
|
|
159
|
+
"GET",
|
|
160
|
+
`/api/v1/workflows/runs/${encodeURIComponent(runId)}/node/${encodeURIComponent(nodeId)}/url`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
/** S9: a rendered diagram of a saved workflow's document, so an agent can
|
|
164
|
+
* see the graph it just built. */
|
|
165
|
+
getWorkflowScreenshot(savedId2) {
|
|
166
|
+
return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}/screenshot`);
|
|
167
|
+
}
|
|
168
|
+
// ── Recipes (S10): published workflow + closed post-run effect list ──
|
|
169
|
+
publishWorkflow(savedId2, body) {
|
|
170
|
+
return this.json("POST", `/api/v1/workflows/${encodeURIComponent(savedId2)}/publish`, { json: body });
|
|
171
|
+
}
|
|
172
|
+
unpublishWorkflow(savedId2) {
|
|
173
|
+
return this.json("DELETE", `/api/v1/workflows/${encodeURIComponent(savedId2)}/publish`);
|
|
174
|
+
}
|
|
175
|
+
listPublished() {
|
|
176
|
+
return this.json("GET", "/api/v1/recipes");
|
|
177
|
+
}
|
|
178
|
+
describePublished(slug2) {
|
|
179
|
+
return this.json("GET", `/api/v1/recipes/${encodeURIComponent(slug2)}`);
|
|
180
|
+
}
|
|
181
|
+
runPublished(slug2, body) {
|
|
182
|
+
return this.json("POST", `/api/v1/recipes/${encodeURIComponent(slug2)}/run`, { json: body });
|
|
183
|
+
}
|
|
184
|
+
saveWorkflow(body) {
|
|
185
|
+
return this.json("POST", "/api/v1/workflows", { json: body });
|
|
186
|
+
}
|
|
187
|
+
// ── Workflow command model: describe, get, create, apply, validate ──
|
|
188
|
+
describeNodeTypes() {
|
|
189
|
+
return this.json("GET", "/api/v1/node-types");
|
|
190
|
+
}
|
|
191
|
+
/** `savedId` carries a `saved:` prefix; the colon is URL-encoded. */
|
|
192
|
+
getWorkflowDocument(savedId2) {
|
|
193
|
+
return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}`);
|
|
194
|
+
}
|
|
195
|
+
/** Create a document by applying `ops` to an empty one (the same code path as apply). */
|
|
196
|
+
createWorkflow(body) {
|
|
197
|
+
return this.json("POST", "/api/v1/workflows", { json: body });
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Apply a patch atomically on top of `patch.baseRevision`. 409 on a stale
|
|
201
|
+
* base; 422 when the workflow is not editable, imports with loss and has not
|
|
202
|
+
* been acknowledged, or the idempotency key belongs to a different patch.
|
|
203
|
+
*/
|
|
204
|
+
applyWorkflowPatch(savedId2, patch) {
|
|
205
|
+
return this.json("PATCH", `/api/v1/workflows/${encodeURIComponent(savedId2)}`, { json: patch });
|
|
206
|
+
}
|
|
207
|
+
/** Stateless dry-run of ops and run params against a document. No side effects. */
|
|
208
|
+
validateWorkflow(body) {
|
|
209
|
+
return this.json("POST", "/api/v1/workflows/validate", { json: body });
|
|
210
|
+
}
|
|
211
|
+
// ── Spend ──
|
|
212
|
+
getSpend(tzOffsetMinutes) {
|
|
213
|
+
const qs = tzOffsetMinutes === void 0 ? "" : `?tz=${encodeURIComponent(String(tzOffsetMinutes))}`;
|
|
214
|
+
return this.json("GET", `/api/spend${qs}`);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The metering read model (docs/plans/2026-08-21-studio-api-keys-and-quotas.md,
|
|
218
|
+
* scope item 2): this key's month-to-date usage against its tenant's plan
|
|
219
|
+
* (cap, rpm), broken down by model.
|
|
220
|
+
*/
|
|
221
|
+
getUsage() {
|
|
222
|
+
return this.json("GET", "/api/v1/usage");
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Download a (signed) asset URL to bytes. Goes through the same fetch so tests
|
|
226
|
+
* can intercept it; no auth header is sent to a foreign host.
|
|
227
|
+
*/
|
|
228
|
+
async downloadUrl(url) {
|
|
229
|
+
const res = await this.#fetchImpl(url, {
|
|
230
|
+
method: "GET",
|
|
231
|
+
headers: { "user-agent": this.#userAgent },
|
|
232
|
+
signal: AbortSignal.timeout(this.#requestTimeoutMs * 4)
|
|
233
|
+
});
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
throw new StudioApiError({
|
|
236
|
+
status: res.status,
|
|
237
|
+
method: "GET",
|
|
238
|
+
path: this.redact(stripQuery(url)),
|
|
239
|
+
message: `download failed (${res.status})`
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const data = await res.blob();
|
|
243
|
+
return { data, filename: filenameFromUrl(url, data.type) };
|
|
244
|
+
}
|
|
245
|
+
// ── Core request ──
|
|
246
|
+
async json(method, path, opts = {}) {
|
|
247
|
+
const headers = {
|
|
248
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
249
|
+
accept: "application/json",
|
|
250
|
+
"user-agent": this.#userAgent
|
|
251
|
+
};
|
|
252
|
+
let body;
|
|
253
|
+
if (opts.form) {
|
|
254
|
+
body = opts.form;
|
|
255
|
+
} else if (opts.json !== void 0) {
|
|
256
|
+
headers["content-type"] = "application/json";
|
|
257
|
+
body = JSON.stringify(opts.json);
|
|
258
|
+
}
|
|
259
|
+
const timeout = opts.form ? this.#requestTimeoutMs * 4 : this.#requestTimeoutMs;
|
|
260
|
+
let res;
|
|
261
|
+
try {
|
|
262
|
+
res = await this.#fetchImpl(`${this.baseUrl}${path}`, {
|
|
263
|
+
method,
|
|
264
|
+
headers,
|
|
265
|
+
body,
|
|
266
|
+
signal: AbortSignal.timeout(timeout)
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
270
|
+
throw new StudioApiError({
|
|
271
|
+
status: 0,
|
|
272
|
+
method,
|
|
273
|
+
path,
|
|
274
|
+
message: this.redact(`network error: ${reason}`)
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const text = await res.text();
|
|
278
|
+
let parsed = text;
|
|
279
|
+
if (text.length > 0) {
|
|
280
|
+
try {
|
|
281
|
+
parsed = JSON.parse(text);
|
|
282
|
+
} catch {
|
|
283
|
+
parsed = text;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!res.ok) {
|
|
287
|
+
const fallback = res.status === 401 || res.status === 403 ? "unauthorized: the API key was rejected" : `HTTP ${res.status}`;
|
|
288
|
+
throw new StudioApiError({
|
|
289
|
+
status: res.status,
|
|
290
|
+
method,
|
|
291
|
+
path,
|
|
292
|
+
message: this.redact(apiMessageFromBody(parsed, fallback)),
|
|
293
|
+
body: typeof parsed === "string" ? this.redact(parsed) : parsed
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (typeof parsed === "string") {
|
|
297
|
+
throw new StudioApiError({
|
|
298
|
+
status: res.status,
|
|
299
|
+
method,
|
|
300
|
+
path,
|
|
301
|
+
message: this.redact(`expected JSON, got: ${parsed.slice(0, 200)}`)
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return parsed;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
function toFile(file) {
|
|
308
|
+
if (file.data instanceof File && file.data.name === file.filename) return file.data;
|
|
309
|
+
return new File([file.data], file.filename, { type: file.data.type });
|
|
310
|
+
}
|
|
311
|
+
function stripQuery(url) {
|
|
312
|
+
const i = url.indexOf("?");
|
|
313
|
+
return i === -1 ? url : url.slice(0, i);
|
|
314
|
+
}
|
|
315
|
+
function filenameFromUrl(url, mime) {
|
|
316
|
+
let base = "asset";
|
|
317
|
+
try {
|
|
318
|
+
const p = new URL(url).pathname;
|
|
319
|
+
const last = p.split("/").filter(Boolean).pop();
|
|
320
|
+
if (last) base = last;
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
if (/\.[a-z0-9]{2,5}$/i.test(base)) return base;
|
|
324
|
+
const ext = extensionForMime(mime);
|
|
325
|
+
return ext ? `${base}.${ext}` : base;
|
|
326
|
+
}
|
|
327
|
+
function extensionForMime(mime) {
|
|
328
|
+
const m = (mime || "").toLowerCase().split(";")[0].trim();
|
|
329
|
+
switch (m) {
|
|
330
|
+
case "image/png":
|
|
331
|
+
return "png";
|
|
332
|
+
case "image/jpeg":
|
|
333
|
+
return "jpg";
|
|
334
|
+
case "image/webp":
|
|
335
|
+
return "webp";
|
|
336
|
+
case "image/gif":
|
|
337
|
+
return "gif";
|
|
338
|
+
case "video/mp4":
|
|
339
|
+
return "mp4";
|
|
340
|
+
default:
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function mimeForFilename(name) {
|
|
345
|
+
const ext = name.toLowerCase().split(".").pop() ?? "";
|
|
346
|
+
switch (ext) {
|
|
347
|
+
case "png":
|
|
348
|
+
return "image/png";
|
|
349
|
+
case "jpg":
|
|
350
|
+
case "jpeg":
|
|
351
|
+
return "image/jpeg";
|
|
352
|
+
case "webp":
|
|
353
|
+
return "image/webp";
|
|
354
|
+
case "gif":
|
|
355
|
+
return "image/gif";
|
|
356
|
+
case "avif":
|
|
357
|
+
return "image/avif";
|
|
358
|
+
case "mp4":
|
|
359
|
+
return "video/mp4";
|
|
360
|
+
default:
|
|
361
|
+
return "application/octet-stream";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/config.ts
|
|
366
|
+
var DEFAULT_BASE_URL = "https://studio.lumitra.co";
|
|
367
|
+
var ConfigError = class extends Error {
|
|
368
|
+
constructor(message) {
|
|
369
|
+
super(message);
|
|
370
|
+
this.name = "ConfigError";
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
var PLACEHOLDER = /^\s*\$\{[^}]*\}\s*$/;
|
|
374
|
+
function isUnexpandedPlaceholder(value) {
|
|
375
|
+
return typeof value === "string" && PLACEHOLDER.test(value);
|
|
376
|
+
}
|
|
377
|
+
function readEnvVar(name, env) {
|
|
378
|
+
const candidates = env === process.env ? [env[name]] : [env[name], process.env[name]];
|
|
379
|
+
for (const raw of candidates) {
|
|
380
|
+
if (typeof raw !== "string") continue;
|
|
381
|
+
const v = raw.trim();
|
|
382
|
+
if (v.length === 0 || isUnexpandedPlaceholder(v)) continue;
|
|
383
|
+
return v;
|
|
384
|
+
}
|
|
385
|
+
return void 0;
|
|
386
|
+
}
|
|
387
|
+
function resolveConfig(env = process.env) {
|
|
388
|
+
const rawBase = readEnvVar("LUMITRA_STUDIO_BASE_URL", env) ?? DEFAULT_BASE_URL;
|
|
389
|
+
let baseUrl;
|
|
390
|
+
try {
|
|
391
|
+
const u = new URL(rawBase);
|
|
392
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("bad protocol");
|
|
393
|
+
baseUrl = u.origin + u.pathname.replace(/\/+$/, "");
|
|
394
|
+
} catch {
|
|
395
|
+
throw new ConfigError(
|
|
396
|
+
`LUMITRA_STUDIO_BASE_URL is not a valid http(s) URL (got ${JSON.stringify(rawBase)})`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const apiKey = readEnvVar("LUMITRA_STUDIO_API_KEY", env) ?? readEnvVar("LUMITRA_STUDIO_SERVICE_TOKEN", env);
|
|
400
|
+
if (!apiKey) {
|
|
401
|
+
throw new ConfigError(
|
|
402
|
+
'Missing API key: set LUMITRA_STUDIO_API_KEY (or the legacy LUMITRA_STUDIO_SERVICE_TOKEN) to a Lumitra Studio bearer token. An unexpanded "${...}" placeholder counts as unset.'
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return { baseUrl, apiKey };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/tools.ts
|
|
409
|
+
import { z as z8 } from "zod";
|
|
410
|
+
|
|
411
|
+
// ../lumitra-core/dist/workflow/index.js
|
|
412
|
+
import { z as z2 } from "zod";
|
|
413
|
+
import { z } from "zod";
|
|
414
|
+
import { z as z3 } from "zod";
|
|
415
|
+
import { z as z4 } from "zod";
|
|
416
|
+
import { z as z5 } from "zod";
|
|
417
|
+
var MODEL_TASKS = [
|
|
418
|
+
"text-to-image",
|
|
419
|
+
"image-to-image",
|
|
420
|
+
"image-edit",
|
|
421
|
+
"virtual-try-on",
|
|
422
|
+
"text-to-video",
|
|
423
|
+
"image-to-video",
|
|
424
|
+
"video-to-video",
|
|
425
|
+
// A finishing pass over a DRAFT video: the upstream draft's cache bundle is
|
|
426
|
+
// the only input, and the output is the full-quality render of the same take.
|
|
427
|
+
"video-enhance",
|
|
428
|
+
"text-to-3d",
|
|
429
|
+
"image-to-3d",
|
|
430
|
+
"multiview-to-3d",
|
|
431
|
+
"remesh",
|
|
432
|
+
"texture",
|
|
433
|
+
"text-to-audio",
|
|
434
|
+
"text-to-speech",
|
|
435
|
+
"speech-to-text",
|
|
436
|
+
"speech-to-speech",
|
|
437
|
+
"image-to-text",
|
|
438
|
+
"image-to-json",
|
|
439
|
+
// A human decision gate over candidate images (P3 J1). Still a model task,
|
|
440
|
+
// not a new node kind: WorkflowNode already requires task + model, and the
|
|
441
|
+
// ranking call underneath is the same image-to-json vision model
|
|
442
|
+
// shotReview.ts uses. The task, ports and wire legality land in J1; the
|
|
443
|
+
// pausing execution state that actually runs a judge node is J2.
|
|
444
|
+
"judge",
|
|
445
|
+
"text-generation",
|
|
446
|
+
"upscale",
|
|
447
|
+
"background-remove",
|
|
448
|
+
"segmentation"
|
|
449
|
+
];
|
|
450
|
+
var PORT_TYPES = ["text", "image", "video", "mask", "mesh3d", "audio", "json"];
|
|
451
|
+
var INPUT_PORT_TYPES = PORT_TYPES.filter((p) => p !== "mask");
|
|
452
|
+
var TASK_PORTS = {
|
|
453
|
+
// GENERATE: born from text (or a mesh prompt), no upstream asset input.
|
|
454
|
+
"text-to-image": { inputs: ["text"], output: "image" },
|
|
455
|
+
"text-to-video": { inputs: ["text"], output: "video" },
|
|
456
|
+
"text-to-3d": { inputs: ["text"], output: "mesh3d" },
|
|
457
|
+
// TRANSFORM: consume an upstream asset, emit another.
|
|
458
|
+
"image-to-image": { inputs: ["image", "text"], output: "image" },
|
|
459
|
+
"image-edit": { inputs: ["image", "text", "mask"], output: "image" },
|
|
460
|
+
// Two image inputs (garment + person); the port type set is coarse (both are
|
|
461
|
+
// 'image'), the two-image contract lives in the fal adapter, not the ports.
|
|
462
|
+
"virtual-try-on": { inputs: ["image"], output: "image" },
|
|
463
|
+
"image-to-video": { inputs: ["image"], output: "video" },
|
|
464
|
+
// Video-to-video (character replace): a source video plus ONE reference image
|
|
465
|
+
// (the person to swap in). The two-input contract lives in the fal adapter.
|
|
466
|
+
"video-to-video": { inputs: ["video", "image"], output: "video" },
|
|
467
|
+
// Video-enhance: the upstream DRAFT video's cache bundle is the whole input.
|
|
468
|
+
// The wire is typed 'video' so it only accepts a video node; whether that
|
|
469
|
+
// node actually produced a draft cache is checked at run time, loudly.
|
|
470
|
+
"video-enhance": { inputs: ["video"], output: "video" },
|
|
471
|
+
"image-to-3d": { inputs: ["image"], output: "mesh3d" },
|
|
472
|
+
"multiview-to-3d": { inputs: ["image"], output: "mesh3d" },
|
|
473
|
+
"remesh": { inputs: ["mesh3d"], output: "mesh3d" },
|
|
474
|
+
"texture": { inputs: ["mesh3d"], output: "mesh3d" },
|
|
475
|
+
// AUDIO + VOICE.
|
|
476
|
+
"text-to-audio": { inputs: ["text"], output: "audio" },
|
|
477
|
+
"text-to-speech": { inputs: ["text"], output: "audio" },
|
|
478
|
+
"speech-to-text": { inputs: ["audio"], output: "text" },
|
|
479
|
+
"speech-to-speech": { inputs: ["audio"], output: "audio" },
|
|
480
|
+
// VISION + LANGUAGE.
|
|
481
|
+
"image-to-text": { inputs: ["image", "text"], output: "text" },
|
|
482
|
+
"image-to-json": { inputs: ["image", "text"], output: "json" },
|
|
483
|
+
// Judge (P3 J1): one candidate wire in (the several results of ONE
|
|
484
|
+
// upstream node-run, not fan-in), the approved candidate out. Both sides
|
|
485
|
+
// are typed 'image' so an approved candidate can feed straight into any
|
|
486
|
+
// node that already accepts an image wire (e.g. image-to-video), with zero
|
|
487
|
+
// change to the downstream port rules.
|
|
488
|
+
"judge": { inputs: ["image"], output: "image" },
|
|
489
|
+
"text-generation": { inputs: ["text"], output: "text" },
|
|
490
|
+
// ENHANCE: a utility pass over an asset.
|
|
491
|
+
"upscale": { inputs: ["image"], output: "image" },
|
|
492
|
+
"background-remove": { inputs: ["image"], output: "image" },
|
|
493
|
+
"segmentation": { inputs: ["image"], output: "mask" }
|
|
494
|
+
};
|
|
495
|
+
function taskPorts(task) {
|
|
496
|
+
return TASK_PORTS[task];
|
|
497
|
+
}
|
|
498
|
+
var JsonValueSchema = z.lazy(
|
|
499
|
+
() => z.union([
|
|
500
|
+
z.string(),
|
|
501
|
+
z.number().finite(),
|
|
502
|
+
z.boolean(),
|
|
503
|
+
z.null(),
|
|
504
|
+
z.array(JsonValueSchema),
|
|
505
|
+
z.record(z.string(), JsonValueSchema)
|
|
506
|
+
])
|
|
507
|
+
);
|
|
508
|
+
var JsonNonNullValueSchema = z.union([
|
|
509
|
+
z.string(),
|
|
510
|
+
z.number().finite(),
|
|
511
|
+
z.boolean(),
|
|
512
|
+
z.array(JsonValueSchema),
|
|
513
|
+
z.record(z.string(), JsonValueSchema)
|
|
514
|
+
]);
|
|
515
|
+
function toJsonValue(value, what = "value") {
|
|
516
|
+
const r = JsonValueSchema.safeParse(value);
|
|
517
|
+
if (!r.success) throw new TypeError(`${what} is not a JSON value: ${r.error.issues[0]?.message ?? "invalid"}`);
|
|
518
|
+
return r.data;
|
|
519
|
+
}
|
|
520
|
+
var WORKFLOW_SCHEMA_VERSION = 1;
|
|
521
|
+
var SchemaVersionSchema = z2.object({
|
|
522
|
+
schema: z2.number().int().positive()
|
|
523
|
+
});
|
|
524
|
+
var LiteralBindingSchema = z2.object({
|
|
525
|
+
kind: z2.literal("literal"),
|
|
526
|
+
value: JsonValueSchema
|
|
527
|
+
});
|
|
528
|
+
var ParamBindingSchema = z2.object({
|
|
529
|
+
kind: z2.literal("param"),
|
|
530
|
+
name: z2.string().min(1),
|
|
531
|
+
// Same ensure-array wrapping as a ref: a single-value input (one image url)
|
|
532
|
+
// wired into an array port (`inputImages`) resolves to a one-element array.
|
|
533
|
+
wrap: z2.literal("array").optional()
|
|
534
|
+
});
|
|
535
|
+
var RefBindingSchema = z2.object({
|
|
536
|
+
kind: z2.literal("ref"),
|
|
537
|
+
node: z2.string().min(1),
|
|
538
|
+
path: z2.string().min(1),
|
|
539
|
+
// Ensure-array wrapping: some job-input ports (e.g. generate_image's
|
|
540
|
+
// `inputImages`) take a string ARRAY while upstream outputs expose single
|
|
541
|
+
// values (`url`). `wrap: "array"` resolves the ref then wraps a non-array
|
|
542
|
+
// value in a one-element array (an existing array passes through), closing
|
|
543
|
+
// the "no array-of-refs" gap without widening the binding grammar itself.
|
|
544
|
+
wrap: z2.literal("array").optional()
|
|
545
|
+
});
|
|
546
|
+
var BindingSchema = z2.discriminatedUnion("kind", [
|
|
547
|
+
LiteralBindingSchema,
|
|
548
|
+
ParamBindingSchema,
|
|
549
|
+
RefBindingSchema
|
|
550
|
+
]);
|
|
551
|
+
var WorkflowTaskSchema = z2.enum(MODEL_TASKS);
|
|
552
|
+
var RetryPolicySchema = z2.object({
|
|
553
|
+
maxAttempts: z2.number().int().positive(),
|
|
554
|
+
backoffMs: z2.number().int().nonnegative()
|
|
555
|
+
});
|
|
556
|
+
var WorkflowNodeSchema = z2.object({
|
|
557
|
+
id: z2.string().min(1),
|
|
558
|
+
task: WorkflowTaskSchema,
|
|
559
|
+
model: z2.string().min(1),
|
|
560
|
+
inputs: z2.record(z2.string(), BindingSchema).default({}),
|
|
561
|
+
retry: RetryPolicySchema.optional()
|
|
562
|
+
});
|
|
563
|
+
var WorkflowParamSpecSchema = z2.object({
|
|
564
|
+
name: z2.string().min(1),
|
|
565
|
+
/** The JSON-level type of the value a caller supplies. */
|
|
566
|
+
type: z2.enum(["string", "number", "boolean", "json"]),
|
|
567
|
+
required: z2.boolean().default(true),
|
|
568
|
+
label: z2.string().optional(),
|
|
569
|
+
description: z2.string().optional(),
|
|
570
|
+
/** The port type the param feeds (what an input NODE declares): an agent
|
|
571
|
+
* needs to know that `still` is an image url, not just "a string". Absent on
|
|
572
|
+
* definitions authored before the workflow command model. */
|
|
573
|
+
inputType: z2.enum(INPUT_PORT_TYPES).optional(),
|
|
574
|
+
/** `many`: the caller supplies a LIST of `inputType` values (several
|
|
575
|
+
* reference images in one param). Absent means one value. */
|
|
576
|
+
cardinality: z2.enum(["one", "many"]).optional(),
|
|
577
|
+
/** Used when a run omits the param. Never null: absent means no default. */
|
|
578
|
+
default: JsonNonNullValueSchema.optional()
|
|
579
|
+
});
|
|
580
|
+
var WorkflowDefinitionSchema = z2.object({
|
|
581
|
+
version: SchemaVersionSchema.default({ schema: WORKFLOW_SCHEMA_VERSION }),
|
|
582
|
+
id: z2.string().min(1),
|
|
583
|
+
label: z2.string().optional(),
|
|
584
|
+
params: z2.array(WorkflowParamSpecSchema).default([]),
|
|
585
|
+
nodes: z2.array(WorkflowNodeSchema).min(1)
|
|
586
|
+
});
|
|
587
|
+
function kahnOrder(ids, edges) {
|
|
588
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
589
|
+
const adj = /* @__PURE__ */ new Map();
|
|
590
|
+
for (const id of ids) {
|
|
591
|
+
indegree.set(id, 0);
|
|
592
|
+
adj.set(id, []);
|
|
593
|
+
}
|
|
594
|
+
for (const [from, to] of edges) {
|
|
595
|
+
const list = adj.get(from);
|
|
596
|
+
if (!list || !indegree.has(to)) continue;
|
|
597
|
+
list.push(to);
|
|
598
|
+
indegree.set(to, indegree.get(to) + 1);
|
|
599
|
+
}
|
|
600
|
+
const queue = [];
|
|
601
|
+
for (const id of ids) if (indegree.get(id) === 0) queue.push(id);
|
|
602
|
+
const order = [];
|
|
603
|
+
for (let head = 0; head < queue.length; head++) {
|
|
604
|
+
const id = queue[head];
|
|
605
|
+
order.push(id);
|
|
606
|
+
for (const next of adj.get(id) ?? []) {
|
|
607
|
+
const d = indegree.get(next) - 1;
|
|
608
|
+
indegree.set(next, d);
|
|
609
|
+
if (d === 0) queue.push(next);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return order.length === ids.length ? order : null;
|
|
613
|
+
}
|
|
614
|
+
var DOCUMENT_SCHEMA_VERSION = 1;
|
|
615
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
616
|
+
var notReserved = (s) => !RESERVED_KEYS.has(s);
|
|
617
|
+
var IdSchema = z3.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/, "id must be 1 to 128 chars of letters, digits, _ . : -").refine(notReserved, "reserved id");
|
|
618
|
+
var KeySchema = z3.string().regex(/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/, "key must be 1 to 64 chars of letters, digits, _ . -").refine(notReserved, "reserved key");
|
|
619
|
+
var InputNameSchema = z3.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/, "input name must be 1 to 64 chars of letters, digits, _ -").refine(notReserved, "reserved name");
|
|
620
|
+
var LabelSchema = z3.string().max(200);
|
|
621
|
+
var DescriptionSchema = z3.string().max(2e3);
|
|
622
|
+
var PromptTextSchema = z3.string().max(2e4);
|
|
623
|
+
var ModelIdSchema = z3.string().max(256);
|
|
624
|
+
var UrlSchema = z3.string().max(4096);
|
|
625
|
+
var AssetIdSchema = z3.string().max(128);
|
|
626
|
+
var PortTypeSchema = z3.enum(PORT_TYPES);
|
|
627
|
+
var InputTypeSchema = z3.enum(INPUT_PORT_TYPES);
|
|
628
|
+
var PositionSchema = z3.object({ x: z3.number().finite(), y: z3.number().finite() });
|
|
629
|
+
var TaskNodeSchema = z3.object({
|
|
630
|
+
id: IdSchema,
|
|
631
|
+
kind: z3.literal("task"),
|
|
632
|
+
task: z3.enum(MODEL_TASKS),
|
|
633
|
+
/** Catalog model id. Empty is a legal EDITING state (the picker is blank);
|
|
634
|
+
* the projection refuses to run it. */
|
|
635
|
+
model: ModelIdSchema.default(""),
|
|
636
|
+
/** The inline literal prompt. A wired text edge (prompt-source or text input)
|
|
637
|
+
* replaces it at projection time. */
|
|
638
|
+
prompt: PromptTextSchema.default(""),
|
|
639
|
+
/** Per-generation modality knobs, keyed in SETTINGS space. Entry-granular ops
|
|
640
|
+
* (`setNodeSetting`) so two actors editing different knobs commute. */
|
|
641
|
+
settings: z3.record(KeySchema, JsonNonNullValueSchema).default({}),
|
|
642
|
+
/** LITERAL inputs the canvas does not reconstruct from prompt/settings/wires
|
|
643
|
+
* (a campaign shot's baked reference stack and lineage literals). Literals
|
|
644
|
+
* only: a `ref` or `param` here would be a dependency the DAG check cannot
|
|
645
|
+
* see. Preserved across projections; a wire over a baked key wins explicitly. */
|
|
646
|
+
bakedInputs: z3.record(KeySchema, LiteralBindingSchema).default({}),
|
|
647
|
+
/** The generation id that feeds downstream. Absent = latest (D4). */
|
|
648
|
+
activeGeneration: IdSchema.optional(),
|
|
649
|
+
label: LabelSchema.optional()
|
|
650
|
+
});
|
|
651
|
+
var PromptSourceNodeSchema = z3.object({
|
|
652
|
+
id: IdSchema,
|
|
653
|
+
kind: z3.literal("prompt-source"),
|
|
654
|
+
text: PromptTextSchema.default(""),
|
|
655
|
+
label: LabelSchema.optional()
|
|
656
|
+
});
|
|
657
|
+
var ImageSourceNodeSchema = z3.object({
|
|
658
|
+
id: IdSchema,
|
|
659
|
+
kind: z3.literal("image-source"),
|
|
660
|
+
/** The uploaded url. Empty or `data:` means still uploading: a legal editing
|
|
661
|
+
* state, refused at projection time. */
|
|
662
|
+
imageUrl: UrlSchema.default(""),
|
|
663
|
+
/** The Storage Brain asset id, when known (E18: every image input resolves to
|
|
664
|
+
* an asset id). */
|
|
665
|
+
assetId: AssetIdSchema.optional(),
|
|
666
|
+
label: LabelSchema.optional()
|
|
667
|
+
});
|
|
668
|
+
var InputNodeSchema = z3.object({
|
|
669
|
+
id: IdSchema,
|
|
670
|
+
kind: z3.literal("input"),
|
|
671
|
+
name: InputNameSchema,
|
|
672
|
+
type: InputTypeSchema,
|
|
673
|
+
required: z3.boolean().default(true),
|
|
674
|
+
/** `many` declares a LIST of values of `type` (several reference images in
|
|
675
|
+
* one param). Absent means one value. A list input can only wire into a
|
|
676
|
+
* port whose binding is an array (`inputImages`); `validateDocument` checks. */
|
|
677
|
+
cardinality: z3.enum(["one", "many"]).optional(),
|
|
678
|
+
/** Used when a run omits the input. Never null: absent means no default. */
|
|
679
|
+
default: JsonNonNullValueSchema.optional(),
|
|
680
|
+
label: LabelSchema.optional(),
|
|
681
|
+
description: DescriptionSchema.optional()
|
|
682
|
+
});
|
|
683
|
+
var DocNodeSchema = z3.discriminatedUnion("kind", [
|
|
684
|
+
TaskNodeSchema,
|
|
685
|
+
PromptSourceNodeSchema,
|
|
686
|
+
ImageSourceNodeSchema,
|
|
687
|
+
InputNodeSchema
|
|
688
|
+
]);
|
|
689
|
+
var DocEdgeSchema = z3.object({
|
|
690
|
+
source: IdSchema,
|
|
691
|
+
target: IdSchema,
|
|
692
|
+
port: PortTypeSchema
|
|
693
|
+
});
|
|
694
|
+
var DocGroupSchema = z3.object({
|
|
695
|
+
id: IdSchema,
|
|
696
|
+
label: LabelSchema.optional(),
|
|
697
|
+
nodeIds: z3.array(IdSchema).min(1)
|
|
698
|
+
});
|
|
699
|
+
var WorkflowDocumentSchema = z3.object({
|
|
700
|
+
schema: z3.number().int().positive(),
|
|
701
|
+
id: IdSchema,
|
|
702
|
+
label: LabelSchema.default(""),
|
|
703
|
+
nodes: z3.array(DocNodeSchema).max(2e3),
|
|
704
|
+
edges: z3.array(DocEdgeSchema).max(8e3),
|
|
705
|
+
layout: z3.record(IdSchema, PositionSchema).default({}),
|
|
706
|
+
groups: z3.array(DocGroupSchema).max(500).default([])
|
|
707
|
+
});
|
|
708
|
+
function edgeKey(e) {
|
|
709
|
+
return JSON.stringify([e.source, e.target, e.port]);
|
|
710
|
+
}
|
|
711
|
+
function slotKey(target, port) {
|
|
712
|
+
return JSON.stringify([target, port]);
|
|
713
|
+
}
|
|
714
|
+
var MODEL_CATALOG = [
|
|
715
|
+
// ── 3D AI Studio: 3D generation ────────────────────────────────────────
|
|
716
|
+
{
|
|
717
|
+
id: "studio3d/tripo-3-1-text-to-3d",
|
|
718
|
+
provider: "studio3d",
|
|
719
|
+
modality: "3d",
|
|
720
|
+
task: "text-to-3d",
|
|
721
|
+
label: "Tripo 3.1 (text-to-3D)",
|
|
722
|
+
description: "Cheapest text-to-3D path on 3D AI Studio. Good for fast iteration.",
|
|
723
|
+
cost: { credits: 20 },
|
|
724
|
+
capabilities: { supportsTextures: true, supportsPbr: true },
|
|
725
|
+
isDefault: true
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
id: "studio3d/tripo-3-1-image-to-3d",
|
|
729
|
+
provider: "studio3d",
|
|
730
|
+
modality: "3d",
|
|
731
|
+
task: "image-to-3d",
|
|
732
|
+
label: "Tripo 3.1 (image-to-3D)",
|
|
733
|
+
cost: { credits: 40 },
|
|
734
|
+
capabilities: { needsImageInput: true, supportsTextures: true, supportsPbr: true }
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
id: "studio3d/hunyuan-3-1-rapid",
|
|
738
|
+
provider: "studio3d",
|
|
739
|
+
modality: "3d",
|
|
740
|
+
task: "text-to-3d",
|
|
741
|
+
label: "Hunyuan 3.1 Rapid",
|
|
742
|
+
description: "Tencent Hunyuan3D, rapid edition. Mid-quality, mid-cost.",
|
|
743
|
+
cost: { credits: 35 },
|
|
744
|
+
capabilities: { supportsTextures: true, supportsPbr: true }
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
id: "studio3d/hunyuan-3-1-pro",
|
|
748
|
+
provider: "studio3d",
|
|
749
|
+
modality: "3d",
|
|
750
|
+
task: "text-to-3d",
|
|
751
|
+
label: "Hunyuan 3.1 Pro",
|
|
752
|
+
description: "Tencent Hunyuan3D, pro edition. High quality, ~3x cost.",
|
|
753
|
+
cost: { credits: 60 },
|
|
754
|
+
capabilities: { supportsTextures: true, supportsPbr: true, maxOutputDetail: "1.5M polys" }
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
id: "studio3d/trellis-2",
|
|
758
|
+
provider: "studio3d",
|
|
759
|
+
modality: "3d",
|
|
760
|
+
task: "image-to-3d",
|
|
761
|
+
label: "Trellis 2",
|
|
762
|
+
description: "Microsoft Trellis 2. Best image-to-3D fidelity. Image input only.",
|
|
763
|
+
cost: { credits: 30 },
|
|
764
|
+
capabilities: { needsImageInput: true, supportsTextures: true, supportsPbr: true, maxOutputDetail: "1024" },
|
|
765
|
+
isDefault: true
|
|
766
|
+
},
|
|
767
|
+
// ── 3D AI Studio: post-processing ──────────────────────────────────────
|
|
768
|
+
{
|
|
769
|
+
id: "studio3d/tripo-remesh",
|
|
770
|
+
provider: "studio3d",
|
|
771
|
+
modality: "3d",
|
|
772
|
+
task: "remesh",
|
|
773
|
+
label: "Tripo Remesh",
|
|
774
|
+
description: "Convert + remesh: target polycount, quad topology, multiple output formats.",
|
|
775
|
+
cost: { credits: 10 },
|
|
776
|
+
isDefault: true
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
id: "studio3d/tripo-texture",
|
|
780
|
+
provider: "studio3d",
|
|
781
|
+
modality: "3d",
|
|
782
|
+
task: "texture",
|
|
783
|
+
label: "Tripo Texture",
|
|
784
|
+
cost: { credits: 20 },
|
|
785
|
+
capabilities: { supportsTextures: true, supportsPbr: true },
|
|
786
|
+
isDefault: true
|
|
787
|
+
},
|
|
788
|
+
// KIE: image gen. Pricing pulled 2026-05-08 from https://kie.ai/pricing
|
|
789
|
+
// (1 credit = $0.005 USD). Indemnification: KIE provides ZERO IP indemnity,
|
|
790
|
+
// so reserve these for internal asset work. v0.1 ships as private internal
|
|
791
|
+
// infra, so KIE is the v0.1 default; revisit if customer-facing output is
|
|
792
|
+
// ever needed.
|
|
793
|
+
{
|
|
794
|
+
// Verified 2026-05-09 via two consecutive CLI smokes at 1:1, 1K, single
|
|
795
|
+
// candidate, default options: KIE billed 8 credits ($0.040) each time.
|
|
796
|
+
// Earlier spec estimate was $0.020 (4 credits); replaced with the measured
|
|
797
|
+
// value. The provider's costUsd from creditsConsumed remains the source of
|
|
798
|
+
// truth for accounting; this catalog field is UI hints only.
|
|
799
|
+
id: "kie/nano-banana-2",
|
|
800
|
+
provider: "kie",
|
|
801
|
+
modality: "image",
|
|
802
|
+
task: "text-to-image",
|
|
803
|
+
label: "Nano Banana 2 (KIE)",
|
|
804
|
+
description: "Google Gemini 2.5 Flash Image via KIE. Kept as fallback aggregator path; default flipped to fal/nano-banana-2 in 2026-06-04 fal slice.",
|
|
805
|
+
cost: { usd: 0.04 }
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
// imagen-4-fast price is estimated; KIE docs do not publish a per-image
|
|
809
|
+
// USD rate. Read data.creditsConsumed from a real run to verify.
|
|
810
|
+
id: "kie/imagen-4-fast",
|
|
811
|
+
provider: "kie",
|
|
812
|
+
modality: "image",
|
|
813
|
+
task: "text-to-image",
|
|
814
|
+
label: "Imagen 4 Fast (KIE)",
|
|
815
|
+
description: "Google Imagen 4 Fast via KIE. No price advantage vs Vertex direct. No IP indemnity.",
|
|
816
|
+
cost: { usd: 0.02 }
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
id: "kie/seedream-v4",
|
|
820
|
+
provider: "kie",
|
|
821
|
+
modality: "image",
|
|
822
|
+
task: "text-to-image",
|
|
823
|
+
label: "Seedream V4 (KIE)",
|
|
824
|
+
description: "ByteDance Seedream V4 via KIE. Stylized output not available on Vertex. No IP indemnity.",
|
|
825
|
+
cost: { usd: 0.0175 }
|
|
826
|
+
},
|
|
827
|
+
// ── fal.ai: image gen ─────────────────────────────────────────────────
|
|
828
|
+
// Prices from docs/internal/research/fal-api-reference.md (vetted
|
|
829
|
+
// 2026-06-04). Nano Banana 2 is the SAME upstream model as today's KIE
|
|
830
|
+
// nano-banana-2 default; flipping the default from kie/nano-banana-2 to
|
|
831
|
+
// fal/nano-banana-2 is zero aesthetic change. FLUX 1.1 [pro] is the
|
|
832
|
+
// fal-native flagship; FLUX.1 [schnell] the fast/cheap option; FLUX.1
|
|
833
|
+
// Kontext [pro] is for instruction edits.
|
|
834
|
+
//
|
|
835
|
+
// Per-task entries (Weave-style classification, option a from the spec):
|
|
836
|
+
// Nano Banana 2 appears as both text-to-image (default) and the /edit
|
|
837
|
+
// endpoint as image-to-image + image-edit, sharing one upstream id.
|
|
838
|
+
{
|
|
839
|
+
id: "fal/nano-banana-2",
|
|
840
|
+
provider: "fal",
|
|
841
|
+
modality: "image",
|
|
842
|
+
task: "text-to-image",
|
|
843
|
+
label: "Nano Banana 2 (fal)",
|
|
844
|
+
description: "Google Gemini 2.5 Flash Image via fal. Default for image gen; same upstream model as KIE nano-banana-2 with no aesthetic change. $0.08 per image (2K x1.5, 4K x2).",
|
|
845
|
+
cost: { usd: 0.08 },
|
|
846
|
+
capabilities: { maxInputImages: 0 },
|
|
847
|
+
isDefault: true
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
id: "fal/nano-banana-2-edit",
|
|
851
|
+
provider: "fal",
|
|
852
|
+
modality: "image",
|
|
853
|
+
task: "image-edit",
|
|
854
|
+
label: "Nano Banana 2 Edit (fal)",
|
|
855
|
+
description: "Nano Banana 2 instruction edit. Takes up to 8 reference images for full brand consistency.",
|
|
856
|
+
cost: { usd: 0.08 },
|
|
857
|
+
capabilities: { needsImageInput: true, maxInputImages: 8 }
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
id: "fal/nano-banana-2-edit-i2i",
|
|
861
|
+
provider: "fal",
|
|
862
|
+
modality: "image",
|
|
863
|
+
task: "image-to-image",
|
|
864
|
+
label: "Nano Banana 2 Image-to-Image (fal)",
|
|
865
|
+
description: "Nano Banana 2 in image-to-image mode; same upstream endpoint as the edit entry, separate task classification.",
|
|
866
|
+
cost: { usd: 0.08 },
|
|
867
|
+
capabilities: { needsImageInput: true, maxInputImages: 8 }
|
|
868
|
+
},
|
|
869
|
+
// ── fal.ai: Reve 2.1 (A/B alternative to Nano Banana for the mint flow) ──
|
|
870
|
+
// Reve's current flagship. Two endpoints mirror the Nano Banana split: the
|
|
871
|
+
// t2i entry generates from text only, the remix entry generates FROM up to 6
|
|
872
|
+
// reference images (image_urls array; prompts may address refs as <img>0</img>).
|
|
873
|
+
// The provider auto-routes the family exactly like Nano Banana: refs present
|
|
874
|
+
// -> remix, no refs -> text-to-image. $0.04 per image on both endpoints
|
|
875
|
+
// (fal pricing page, verified 2026-07-24).
|
|
876
|
+
{
|
|
877
|
+
id: "fal/reve",
|
|
878
|
+
provider: "fal",
|
|
879
|
+
modality: "image",
|
|
880
|
+
task: "text-to-image",
|
|
881
|
+
label: "Reve 2.1 (fal)",
|
|
882
|
+
description: "Reve 2.1 text-to-image via fal. Native 4K, strong prompt adherence; $0.04 per image. Text-only (refs route to the remix entry).",
|
|
883
|
+
cost: { usd: 0.04 },
|
|
884
|
+
capabilities: { maxInputImages: 0 }
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
id: "fal/reve-remix",
|
|
888
|
+
provider: "fal",
|
|
889
|
+
modality: "image",
|
|
890
|
+
task: "image-edit",
|
|
891
|
+
label: "Reve 2.1 Remix (fal)",
|
|
892
|
+
description: "Reve 2.1 remix via fal: generates from up to 6 reference images plus a prompt; $0.04 per image. The mint-flow A/B counterpart to Nano Banana 2 Edit.",
|
|
893
|
+
cost: { usd: 0.04 },
|
|
894
|
+
capabilities: { needsImageInput: true, maxInputImages: 6 }
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
id: "fal/flux-1-1-pro",
|
|
898
|
+
provider: "fal",
|
|
899
|
+
modality: "image",
|
|
900
|
+
task: "text-to-image",
|
|
901
|
+
label: "FLUX 1.1 [pro] (fal)",
|
|
902
|
+
description: "Black Forest Labs FLUX 1.1 [pro] via fal. Flagship text-to-image; $0.04 per megapixel. Text-only, no reference-image brand conditioning.",
|
|
903
|
+
cost: { usd: 0.04 },
|
|
904
|
+
capabilities: { maxInputImages: 0 }
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
id: "fal/flux-schnell",
|
|
908
|
+
provider: "fal",
|
|
909
|
+
modality: "image",
|
|
910
|
+
task: "text-to-image",
|
|
911
|
+
label: "FLUX.1 [schnell] (fal)",
|
|
912
|
+
description: "Black Forest Labs FLUX.1 [schnell] via fal. Fast/cheap text-to-image; $0.003 per megapixel. Text-only.",
|
|
913
|
+
cost: { usd: 3e-3 },
|
|
914
|
+
capabilities: { maxInputImages: 0 }
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
id: "fal/flux-kontext-pro",
|
|
918
|
+
provider: "fal",
|
|
919
|
+
modality: "image",
|
|
920
|
+
task: "image-edit",
|
|
921
|
+
label: "FLUX.1 Kontext [pro] (fal)",
|
|
922
|
+
description: "FLUX.1 Kontext instruction edit via fal. Single image_url input ($0.04 per image). For multi-ref, the kontext/max/multi endpoint is a follow-up.",
|
|
923
|
+
cost: { usd: 0.04 },
|
|
924
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
925
|
+
},
|
|
926
|
+
// ── fal.ai: virtual try-on (E3) ────────────────────────────────────────
|
|
927
|
+
// FASHN garment-preserving try-on. A DISTINCT task from image-edit: it takes
|
|
928
|
+
// TWO named images (garment + person/model) and preserves the garment's
|
|
929
|
+
// pixels (text/pattern/fabric) rather than letting a general diffusion edit
|
|
930
|
+
// redraw them. Id, schema, and price verified 2026-07-20 against fal's model
|
|
931
|
+
// page for fal-ai/fashn/tryon/v1.6; see docs/internal/research/fal-fashn-tryon.md.
|
|
932
|
+
// Flat $0.075 per run (fal does not return cost; the provider computes it).
|
|
933
|
+
{
|
|
934
|
+
id: "fal/fashn-tryon-v1-6",
|
|
935
|
+
provider: "fal",
|
|
936
|
+
modality: "image",
|
|
937
|
+
task: "virtual-try-on",
|
|
938
|
+
label: "FASHN Try-On v1.6",
|
|
939
|
+
description: "Garment-preserving virtual try-on via fal. Preserves product text/pattern/fabric; takes a garment image and a model/person image. $0.075 per run (fal-ai/fashn/tryon/v1.6, vetted 2026-07-20).",
|
|
940
|
+
cost: { usd: 0.075 },
|
|
941
|
+
capabilities: { maxInputImages: 2, needsImageInput: true },
|
|
942
|
+
isDefault: true
|
|
943
|
+
},
|
|
944
|
+
// ── fal.ai: video gen (B1) ─────────────────────────────────────────────
|
|
945
|
+
// Async via fal's queue API (submit -> webhook / poll). Prices are per the
|
|
946
|
+
// fal model pages (vetted 2026-06-16); fal does NOT return cost, so the
|
|
947
|
+
// provider computes a flat per-video estimate from the catalog usd anchored
|
|
948
|
+
// to the model's base clip length. Exactly one isDefault per (video, task):
|
|
949
|
+
// Kling 2.5 Turbo Pro for image-to-video, Seedance 1.0 Pro for text-to-video.
|
|
950
|
+
{
|
|
951
|
+
id: "fal/kling-v2-5-image-to-video",
|
|
952
|
+
provider: "fal",
|
|
953
|
+
modality: "video",
|
|
954
|
+
task: "image-to-video",
|
|
955
|
+
label: "Kling 2.5 Turbo Pro (image-to-video)",
|
|
956
|
+
description: "Kuaishou Kling 2.5 Turbo Pro via fal. Cinematic image-to-video; $0.35 base (5s) plus $0.07 per extra second. Default image-to-video.",
|
|
957
|
+
cost: { usd: 0.35 },
|
|
958
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
959
|
+
isDefault: true
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
id: "fal/seedance-pro-image-to-video",
|
|
963
|
+
provider: "fal",
|
|
964
|
+
modality: "video",
|
|
965
|
+
task: "image-to-video",
|
|
966
|
+
label: "Seedance 1.0 Pro (image-to-video)",
|
|
967
|
+
description: "ByteDance Seedance 1.0 Pro via fal. Image-to-video; roughly $0.62 per 1080p 5s clip. Alternative to Kling for stylized motion.",
|
|
968
|
+
cost: { usd: 0.62 },
|
|
969
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
id: "fal/seedance-2-5-image-to-video",
|
|
973
|
+
provider: "fal",
|
|
974
|
+
modality: "video",
|
|
975
|
+
task: "image-to-video",
|
|
976
|
+
label: "Seedance 2.5 (image-to-video)",
|
|
977
|
+
description: "ByteDance Seedance 2.5 via fal. Accepts a start frame AND an end frame (end_image_url), selectable duration 4-30s, up to 1080p, with synchronised audio (generate_audio, on by default). Token-billed: (w*h*s*24)/1024 at $0.0214 per 1000 tokens, so roughly $1.89 for 4s at 720p and $4.16 at 1080p. CAUTION: ByteDance partner moderation on 2.5 rejected even fal's own robot test image with content_policy_violation (observed 2026-08-21), so it may refuse input that 2.0 accepts. Try 2.0 first for photographic input.",
|
|
978
|
+
cost: { usd: 1.89 },
|
|
979
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: "fal/seedance-2-0-image-to-video",
|
|
983
|
+
provider: "fal",
|
|
984
|
+
modality: "video",
|
|
985
|
+
task: "image-to-video",
|
|
986
|
+
label: "Seedance 2.0 (image-to-video)",
|
|
987
|
+
description: "ByteDance Seedance 2.0 via fal. Start frame plus optional end frame (end_image_url), duration 4-15s, up to 4k, and it accepts seven explicit aspect ratios where 2.5 is fixed to auto. Not simply superseded by 2.5: 2.0 generated from photographic input that 2.5 refused on content policy (observed 2026-08-21), and it is the only one offering 4k. Token-billed identically to 2.5.",
|
|
988
|
+
cost: { usd: 1.89 },
|
|
989
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
990
|
+
},
|
|
991
|
+
// ── FLUX.3 (Black Forest Labs) ───────────────────────────────────────────
|
|
992
|
+
// One model, three tiers of the same three input shapes:
|
|
993
|
+
// draft $0.06/s, fixed 720p, plus a draft_cache bundle (preview)
|
|
994
|
+
// direct $0.17/s at 720p, $0.29/s at 1080p, one shot (finished)
|
|
995
|
+
// enhance $0.29/s, fixed 1080p, from the cache only (finished)
|
|
996
|
+
// Draft + enhance totals $0.35/s, a 21% premium over direct 1080p on a first-try
|
|
997
|
+
// keeper and cheaper from the second attempt on, which is exactly the
|
|
998
|
+
// generate-several-pick-one shape. Use direct when one confident render is
|
|
999
|
+
// enough or 720p is the deliverable; use draft + enhance when a human picks.
|
|
1000
|
+
// The completion handler mirrors each draft's cache into Storage Brain, so an
|
|
1001
|
+
// approval gate between the two can take as long as it needs (measured: the
|
|
1002
|
+
// bundle is ~29 KB and fal enhances from a re-hosted copy, 2026-08-23).
|
|
1003
|
+
// Different model owner than Seedance, so ByteDance partner moderation does
|
|
1004
|
+
// not apply here. That changes which filter runs, and nothing about whether we
|
|
1005
|
+
// hold the depicted person's consent.
|
|
1006
|
+
{
|
|
1007
|
+
id: "fal/flux-3-image-to-video-draft",
|
|
1008
|
+
provider: "fal",
|
|
1009
|
+
modality: "video",
|
|
1010
|
+
task: "image-to-video",
|
|
1011
|
+
label: "FLUX.3 draft (image-to-video)",
|
|
1012
|
+
description: "Black Forest Labs FLUX.3 draft tier via fal. Single opening keyframe, duration auto or 5-20s, fixed 720p, audio ON by default (generate_audio). $0.06 per second of draft. Returns a draft_cache for a later full-quality enhance pass, which bills separately. Accepts no resolution, seed or negative prompt. PREVIEW QUALITY: do not ship a draft as a deliverable.",
|
|
1013
|
+
cost: { usd: 0.3 },
|
|
1014
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, emitsDraftCache: true }
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
id: "fal/flux-3-first-last-frame-to-video-draft",
|
|
1018
|
+
provider: "fal",
|
|
1019
|
+
modality: "video",
|
|
1020
|
+
task: "image-to-video",
|
|
1021
|
+
label: "FLUX.3 draft (first + last frame)",
|
|
1022
|
+
description: "Black Forest Labs FLUX.3 draft tier via fal. Start AND end keyframe, duration 5-20s, fixed 720p, audio ON by default. $0.06 per second of draft, roughly a third of Kling 2.5 for the same two-keyframe shot. Note it names the start frame start_image_url, not image_url. Minimum 5s, so a 4s cut means rendering 5s and trimming. PREVIEW QUALITY: enhance before shipping.",
|
|
1023
|
+
cost: { usd: 0.3 },
|
|
1024
|
+
capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true }
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
id: "fal/flux-3-keyframes-to-video-draft",
|
|
1028
|
+
provider: "fal",
|
|
1029
|
+
modality: "video",
|
|
1030
|
+
task: "image-to-video",
|
|
1031
|
+
label: "FLUX.3 draft (keyframes)",
|
|
1032
|
+
description: "Black Forest Labs FLUX.3 draft tier via fal. Up to 10 images pinned to positions on a 24 fps timeline (params.keyframes as [{ imageUrl, frameIndex }]), duration 5-20s, fixed 720p, audio ON by default. $0.06 per second of draft. The only endpoint here that expresses motion through more than two keyframes, so it can describe a pass-by or a multi-beat action that a first/last pair structurally cannot. Falls back to deriving two keyframes from the standard start/end inputs. PREVIEW QUALITY: enhance before shipping.",
|
|
1033
|
+
cost: { usd: 0.3 },
|
|
1034
|
+
capabilities: { needsImageInput: true, maxInputImages: 10, emitsDraftCache: true }
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
id: "fal/flux-3-image-to-video",
|
|
1038
|
+
provider: "fal",
|
|
1039
|
+
modality: "video",
|
|
1040
|
+
task: "image-to-video",
|
|
1041
|
+
label: "FLUX.3 (image-to-video)",
|
|
1042
|
+
description: "Black Forest Labs FLUX.3 direct tier via fal. Single opening keyframe, duration auto or 5-20s, resolution 720p (default) or 1080p, audio ON by default. $0.17 per second at 720p, $0.29 at 1080p; the catalog usd anchors 5s at 720p. One-shot finished render, returns the seed. Use when one confident render is enough; for iterate-then-pick, draft + enhance is cheaper.",
|
|
1043
|
+
cost: { usd: 0.85 },
|
|
1044
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
id: "fal/flux-3-first-last-frame-to-video",
|
|
1048
|
+
provider: "fal",
|
|
1049
|
+
modality: "video",
|
|
1050
|
+
task: "image-to-video",
|
|
1051
|
+
label: "FLUX.3 (first + last frame)",
|
|
1052
|
+
description: "Black Forest Labs FLUX.3 direct tier via fal. Start AND end keyframe (start_image_url / end_image_url), duration 5-20s, resolution 720p (default) or 1080p, audio ON by default. $0.17 per second at 720p, $0.29 at 1080p; the catalog usd anchors 5s at 720p. Minimum 5s, so a 4s cut means rendering 5s and trimming. Finished quality, returns the seed.",
|
|
1053
|
+
cost: { usd: 0.85 },
|
|
1054
|
+
capabilities: { needsImageInput: true, maxInputImages: 2 }
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
id: "fal/flux-3-keyframes-to-video",
|
|
1058
|
+
provider: "fal",
|
|
1059
|
+
modality: "video",
|
|
1060
|
+
task: "image-to-video",
|
|
1061
|
+
label: "FLUX.3 (keyframes)",
|
|
1062
|
+
description: "Black Forest Labs FLUX.3 direct tier via fal. Up to 10 images pinned to positions on a 24 fps timeline (params.keyframes as [{ imageUrl, frameIndex }]), duration 5-20s, resolution 720p (default) or 1080p, audio ON by default. $0.17 per second at 720p, $0.29 at 1080p; the catalog usd anchors 5s at 720p. Multi-beat actions and pass-bys that a first/last pair cannot express. Finished quality, returns the seed.",
|
|
1063
|
+
cost: { usd: 0.85 },
|
|
1064
|
+
capabilities: { needsImageInput: true, maxInputImages: 10 }
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
id: "fal/flux-3-draft-enhance",
|
|
1068
|
+
provider: "fal",
|
|
1069
|
+
modality: "video",
|
|
1070
|
+
task: "video-enhance",
|
|
1071
|
+
label: "FLUX.3 enhance (draft to 1080p)",
|
|
1072
|
+
description: "Black Forest Labs FLUX.3 draft-enhance via fal. Input is ONLY the draft_cache of a FLUX.3 draft node (wire the draft node in; nothing else can be enhanced, only re-rendered). Renders the same take, same motion and composition, at a fixed 1080p with synchronized audio. $0.29 per second of enhanced video; the catalog usd anchors 5s. No prompt, duration, aspect or audio knobs (all baked into the cache); safety_tolerance only. Output has no seed or cache.",
|
|
1073
|
+
cost: { usd: 1.45 },
|
|
1074
|
+
capabilities: { needsImageInput: false, maxInputImages: 0 },
|
|
1075
|
+
isDefault: true
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
id: "fal/seedance-pro-text-to-video",
|
|
1079
|
+
provider: "fal",
|
|
1080
|
+
modality: "video",
|
|
1081
|
+
task: "text-to-video",
|
|
1082
|
+
label: "Seedance 1.0 Pro (text-to-video)",
|
|
1083
|
+
description: "ByteDance Seedance 1.0 Pro via fal. Text-to-video; roughly $0.62 per 1080p 5s clip. Default text-to-video.",
|
|
1084
|
+
cost: { usd: 0.62 },
|
|
1085
|
+
capabilities: { maxInputImages: 0 },
|
|
1086
|
+
isDefault: true
|
|
1087
|
+
},
|
|
1088
|
+
// Video-to-video: Wan 2.2 Animate Replace swaps the person in a source video
|
|
1089
|
+
// for the character in ONE reference image, keeping scene + motion. Priced
|
|
1090
|
+
// per video second by resolution (fal pricing, vetted 2026-08-20): 480p $0.04,
|
|
1091
|
+
// 580p $0.06, 720p $0.08. The catalog usd anchors a 5s clip at 720p, the same
|
|
1092
|
+
// way Kling anchors its base clip; the provider scales by duration + resolution.
|
|
1093
|
+
// Output is 50fps, 720p max. Only model for the task, so it is the default.
|
|
1094
|
+
{
|
|
1095
|
+
id: "fal/wan-animate-replace",
|
|
1096
|
+
provider: "fal",
|
|
1097
|
+
modality: "video",
|
|
1098
|
+
task: "video-to-video",
|
|
1099
|
+
label: "Wan 2.2 Animate Replace (video-to-video)",
|
|
1100
|
+
description: "Wan 2.2 14B Animate Replace via fal. Replaces the person in a source video with the character from one reference image, preserving motion and scene. $0.04 (480p) / $0.06 (580p) / $0.08 (720p) per video second. Default video-to-video.",
|
|
1101
|
+
cost: { usd: 0.4 },
|
|
1102
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1103
|
+
isDefault: true
|
|
1104
|
+
},
|
|
1105
|
+
// ── fal.ai: vision + utility (B4) ──────────────────────────────────────
|
|
1106
|
+
// All sync via fal's subscribe. Two return text/json (vision), two return a
|
|
1107
|
+
// new image (upscale / background-remove). fal bills these per compute second
|
|
1108
|
+
// and does NOT return a per-call cost, so the catalog usd is a UI hint only
|
|
1109
|
+
// (the provider leaves costUsd undefined, same as an unknown image endpoint).
|
|
1110
|
+
// Endpoint ids + schemas verified 2026-06-16 against the fal model /api pages.
|
|
1111
|
+
// Modality stays `image` (these consume / produce images); one isDefault per
|
|
1112
|
+
// (image, task) for the four new tasks.
|
|
1113
|
+
{
|
|
1114
|
+
id: "fal/florence-2-detailed-caption",
|
|
1115
|
+
provider: "fal",
|
|
1116
|
+
modality: "image",
|
|
1117
|
+
task: "image-to-text",
|
|
1118
|
+
label: "Florence-2 Detailed Caption (fal)",
|
|
1119
|
+
description: "Microsoft Florence-2 Large detailed caption via fal. Returns a rich text description of an image (caption -> job.output.text, no asset). Default image-to-text.",
|
|
1120
|
+
cost: { usd: 3e-3 },
|
|
1121
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1122
|
+
isDefault: true
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
id: "fal/moondream-next-vqa",
|
|
1126
|
+
provider: "fal",
|
|
1127
|
+
modality: "image",
|
|
1128
|
+
task: "image-to-json",
|
|
1129
|
+
label: "MoonDreamNext VQA (fal)",
|
|
1130
|
+
description: "MoonDreamNext visual question answering via fal. Answers a prompt about an image; the provider parses the answer into structured JSON (-> job.output.json, no asset). Default image-to-json.",
|
|
1131
|
+
cost: { usd: 3e-3 },
|
|
1132
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1133
|
+
isDefault: true
|
|
1134
|
+
},
|
|
1135
|
+
// Judge (P3 J1). The same MoonDreamNext VQA endpoint `fal/moondream-next-vqa`
|
|
1136
|
+
// above already runs shotReview.ts's ranking call, under a separate catalog
|
|
1137
|
+
// id because a catalog entry's `task` is fixed and `listByTask('judge')`
|
|
1138
|
+
// needs at least one entry to give a placed judge node a selectable default
|
|
1139
|
+
// model. Not dispatched yet: `judge` runs as a human gate (`taskExecution`
|
|
1140
|
+
// in task-kind.ts) and makes no model call at all until J5 wires the actual
|
|
1141
|
+
// ranking back onto shotReview.ts rather than a second implementation.
|
|
1142
|
+
{
|
|
1143
|
+
id: "fal/moondream-next-vqa-judge",
|
|
1144
|
+
provider: "fal",
|
|
1145
|
+
modality: "image",
|
|
1146
|
+
task: "judge",
|
|
1147
|
+
label: "MoonDreamNext VQA (judge ranking)",
|
|
1148
|
+
description: "The judge node's ranking call: the same MoonDreamNext VQA endpoint shotReview.ts already uses to score a candidate image. A judge node runs as a human GATE (it parks the run on a decision, see taskExecution), so it has no job kind; this model is what P3 J5 will rank candidates with. Default judge.",
|
|
1149
|
+
cost: { usd: 3e-3 },
|
|
1150
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1151
|
+
isDefault: true
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
id: "fal/aura-sr-upscale",
|
|
1155
|
+
provider: "fal",
|
|
1156
|
+
modality: "image",
|
|
1157
|
+
task: "upscale",
|
|
1158
|
+
label: "AuraSR Upscale (fal)",
|
|
1159
|
+
description: "AuraSR GigaGAN upscaler via fal. Produces a higher-resolution Image asset from a source image (default 4x). Default upscale. Compute-second billed; catalog usd is a hint.",
|
|
1160
|
+
cost: { usd: 0.01 },
|
|
1161
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1162
|
+
isDefault: true
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
id: "fal/birefnet-v2-background-remove",
|
|
1166
|
+
provider: "fal",
|
|
1167
|
+
modality: "image",
|
|
1168
|
+
task: "background-remove",
|
|
1169
|
+
label: "BiRefNet v2 Background Removal (fal)",
|
|
1170
|
+
description: "BiRefNet v2 background removal via fal. Produces a transparent-background PNG Image asset from a source image. Default background-remove. Compute-second billed; catalog usd is a hint.",
|
|
1171
|
+
cost: { usd: 0.01 },
|
|
1172
|
+
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1173
|
+
isDefault: true
|
|
1174
|
+
},
|
|
1175
|
+
// ── audio: music via Suno-through-KIE (B2) ─────────────────────────────
|
|
1176
|
+
// Async via KIE's Suno job API (createTask -> webhook / record-info poll).
|
|
1177
|
+
// KIE does not return a per-song USD on the result, so the catalog usd is a
|
|
1178
|
+
// flat per-song estimate (~$0.03-$0.04 per song per audio-providers.md, KIE's
|
|
1179
|
+
// markup keeps it in that band). V5 is Suno's current top model.
|
|
1180
|
+
{
|
|
1181
|
+
id: "kie/suno-v5",
|
|
1182
|
+
provider: "kie",
|
|
1183
|
+
modality: "audio",
|
|
1184
|
+
task: "text-to-audio",
|
|
1185
|
+
label: "Suno V5 (KIE)",
|
|
1186
|
+
description: "Suno V5 music generation via KIE. Text-to-music with or without lyrics; ~4 min cap per generation. Default text-to-audio. No IP indemnity (KIE aggregator).",
|
|
1187
|
+
cost: { usd: 0.04 },
|
|
1188
|
+
isDefault: true
|
|
1189
|
+
},
|
|
1190
|
+
// ── audio: voice via ElevenLabs (B2) ───────────────────────────────────
|
|
1191
|
+
// Sync against the official ElevenLabs API (xi-api-key). TTS returns audio
|
|
1192
|
+
// bytes inline; STT returns a transcript (text, not a binary asset). Prices
|
|
1193
|
+
// per ElevenLabs usage tiers (audio-providers.md): TTS ~ $0.0003 per char on
|
|
1194
|
+
// a paid tier, STT ~ $0.40 per audio hour; the catalog usd is a per-call
|
|
1195
|
+
// hint, the provider does not bill per call so cost stays approximate.
|
|
1196
|
+
{
|
|
1197
|
+
id: "elevenlabs/tts-multilingual-v2",
|
|
1198
|
+
provider: "elevenlabs",
|
|
1199
|
+
modality: "audio",
|
|
1200
|
+
task: "text-to-speech",
|
|
1201
|
+
label: "ElevenLabs Multilingual v2 (TTS)",
|
|
1202
|
+
description: "ElevenLabs Multilingual v2 text-to-speech. 99+ languages, brand-voice cloning capable. Default text-to-speech. Returns mp3 bytes synchronously.",
|
|
1203
|
+
cost: { usd: 0.03 },
|
|
1204
|
+
isDefault: true
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
id: "elevenlabs/scribe-v1",
|
|
1208
|
+
provider: "elevenlabs",
|
|
1209
|
+
modality: "audio",
|
|
1210
|
+
task: "speech-to-text",
|
|
1211
|
+
label: "ElevenLabs Scribe v1 (STT)",
|
|
1212
|
+
description: "ElevenLabs Scribe v1 speech-to-text. Whisper-class transcription; returns a transcript (text), not a binary asset. Default speech-to-text.",
|
|
1213
|
+
cost: { usd: 0.01 },
|
|
1214
|
+
isDefault: true
|
|
1215
|
+
},
|
|
1216
|
+
// ── text: LLM director (B3) ────────────────────────────────────────────
|
|
1217
|
+
// The director's brain, NOT an asset-generation path: prompt improvement and
|
|
1218
|
+
// shot descriptions via a synchronous TextClient (no Jobs, no Assets). KIE
|
|
1219
|
+
// exposes Claude (Sonnet / Opus) at a lower rate than the official API via an
|
|
1220
|
+
// Anthropic-Messages-compatible endpoint (api.kie.ai/claude/v1/messages,
|
|
1221
|
+
// vetted 2026-06-16); OpenRouter routes the same Claude family through its
|
|
1222
|
+
// OpenAI chat/completions surface and returns the authoritative usage.cost.
|
|
1223
|
+
// KIE / OpenRouter report real cost per call, so the catalog usd is a UI hint.
|
|
1224
|
+
// Exactly one isDefault for (text, text-generation): KIE Sonnet (cheapest
|
|
1225
|
+
// reliable director path with no IP-indemnity concern for internal use).
|
|
1226
|
+
{
|
|
1227
|
+
id: "kie/claude-sonnet-4-5",
|
|
1228
|
+
provider: "kie",
|
|
1229
|
+
modality: "text",
|
|
1230
|
+
task: "text-generation",
|
|
1231
|
+
label: "Claude Sonnet 4.5 (KIE)",
|
|
1232
|
+
description: "Anthropic Claude Sonnet 4.5 via KIE (Anthropic Messages API at a lower rate). Default LLM director: prompt improvement + shot descriptions. Cost billed per token; catalog usd is a hint.",
|
|
1233
|
+
cost: { usd: 0.01 },
|
|
1234
|
+
isDefault: true
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
id: "kie/claude-opus-4-5",
|
|
1238
|
+
provider: "kie",
|
|
1239
|
+
modality: "text",
|
|
1240
|
+
task: "text-generation",
|
|
1241
|
+
label: "Claude Opus 4.5 (KIE)",
|
|
1242
|
+
description: "Anthropic Claude Opus 4.5 via KIE. Higher quality, higher cost; use for harder director reasoning. Cost billed per token; catalog usd is a hint.",
|
|
1243
|
+
cost: { usd: 0.05 }
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
id: "openrouter/claude-sonnet-4-6",
|
|
1247
|
+
provider: "openrouter",
|
|
1248
|
+
modality: "text",
|
|
1249
|
+
task: "text-generation",
|
|
1250
|
+
label: "Claude Sonnet 4.6 (OpenRouter)",
|
|
1251
|
+
description: "Anthropic Claude Sonnet 4.6 via OpenRouter (OpenAI chat/completions shape). Alternative director path; returns authoritative usage.cost per request.",
|
|
1252
|
+
cost: { usd: 0.012 }
|
|
1253
|
+
},
|
|
1254
|
+
// ── Vertex AI: fallback path, kept unwired for v0.1 ────────────────────
|
|
1255
|
+
// Provider code is still in place so a future flip back is one isDefault
|
|
1256
|
+
// move. Activating any of these requires GOOGLE_APPLICATION_CREDENTIALS_JSON
|
|
1257
|
+
// in Infisical prod, which v0.1 explicitly skips.
|
|
1258
|
+
{
|
|
1259
|
+
id: "vertex/gemini-2-5-flash-image",
|
|
1260
|
+
provider: "vertex",
|
|
1261
|
+
modality: "image",
|
|
1262
|
+
task: "text-to-image",
|
|
1263
|
+
label: "Gemini 2.5 Flash Image",
|
|
1264
|
+
description: "Vertex AI direct. Fallback if KIE pricing or reliability turns sour. Needs GAC_JSON provisioning to activate."
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
id: "vertex/gemini-3-pro-image-preview",
|
|
1268
|
+
provider: "vertex",
|
|
1269
|
+
modality: "image",
|
|
1270
|
+
task: "text-to-image",
|
|
1271
|
+
label: "Nano Banana Pro (preview)"
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
id: "vertex/gemini-3-1-flash-image-preview",
|
|
1275
|
+
provider: "vertex",
|
|
1276
|
+
modality: "image",
|
|
1277
|
+
task: "text-to-image",
|
|
1278
|
+
label: "Gemini 3.1 Flash Image (preview)",
|
|
1279
|
+
description: "Vertex AI direct, newest flash variant. Global region. Preview status."
|
|
1280
|
+
}
|
|
1281
|
+
];
|
|
1282
|
+
function tryGetModel(id) {
|
|
1283
|
+
return MODEL_CATALOG.find((m) => m.id === id);
|
|
1284
|
+
}
|
|
1285
|
+
var PROMPT_REQUIRED = /* @__PURE__ */ new Set([
|
|
1286
|
+
"text-to-image",
|
|
1287
|
+
"image-to-image",
|
|
1288
|
+
"image-edit",
|
|
1289
|
+
"text-to-3d",
|
|
1290
|
+
"image-to-3d",
|
|
1291
|
+
"multiview-to-3d",
|
|
1292
|
+
"text-to-video",
|
|
1293
|
+
// Audio is prompt-only: a music description (text-to-audio) / the text to
|
|
1294
|
+
// speak (text-to-speech). No upstream wire, so no portBinding entry.
|
|
1295
|
+
"text-to-audio",
|
|
1296
|
+
"text-to-speech"
|
|
1297
|
+
]);
|
|
1298
|
+
var PROMPT_OPTIONAL = /* @__PURE__ */ new Set(["image-to-video", "image-to-json", "texture"]);
|
|
1299
|
+
function taskNeedsPrompt(task) {
|
|
1300
|
+
return PROMPT_REQUIRED.has(task) || PROMPT_OPTIONAL.has(task);
|
|
1301
|
+
}
|
|
1302
|
+
function taskRequiresPrompt(task) {
|
|
1303
|
+
return PROMPT_REQUIRED.has(task);
|
|
1304
|
+
}
|
|
1305
|
+
function promptInputKey(task) {
|
|
1306
|
+
return task === "texture" ? "texturePrompt" : "prompt";
|
|
1307
|
+
}
|
|
1308
|
+
function portBinding(task, port) {
|
|
1309
|
+
if (port === "image" && (task === "image-to-3d" || task === "multiview-to-3d" || task === "image-to-video" || task === "image-to-text" || task === "image-to-json")) {
|
|
1310
|
+
return { key: "inputImageUrl", path: "url" };
|
|
1311
|
+
}
|
|
1312
|
+
if (port === "mesh3d" && (task === "remesh" || task === "texture")) {
|
|
1313
|
+
return { key: "parentAssetId", path: "assetId" };
|
|
1314
|
+
}
|
|
1315
|
+
if (port === "video" && task === "video-to-video") {
|
|
1316
|
+
return { key: "inputVideoUrl", path: "url" };
|
|
1317
|
+
}
|
|
1318
|
+
if (port === "image" && task === "video-to-video") {
|
|
1319
|
+
return { key: "inputImages", path: "url", wrap: "array" };
|
|
1320
|
+
}
|
|
1321
|
+
if (port === "video" && task === "video-enhance") {
|
|
1322
|
+
return { key: "draftCacheUrl", path: "draftCacheUrl" };
|
|
1323
|
+
}
|
|
1324
|
+
if (port === "image" && task === "image-edit") {
|
|
1325
|
+
return { key: "inputImages", path: "url", wrap: "array" };
|
|
1326
|
+
}
|
|
1327
|
+
if (port === "text" && taskNeedsPrompt(task)) {
|
|
1328
|
+
return { key: promptInputKey(task), path: "text" };
|
|
1329
|
+
}
|
|
1330
|
+
if (port === "image" && (task === "upscale" || task === "background-remove")) {
|
|
1331
|
+
return { key: "parentAssetId", path: "assetId" };
|
|
1332
|
+
}
|
|
1333
|
+
if (port === "audio" && task === "speech-to-text") {
|
|
1334
|
+
return { key: "inputAudioUrl", path: "url" };
|
|
1335
|
+
}
|
|
1336
|
+
if (port === "image" && task === "judge") {
|
|
1337
|
+
return { key: "candidateAssetIds", path: "assetId", wrap: "array" };
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
function canConnect(sourceOut, targetTask, targetPort) {
|
|
1342
|
+
if (sourceOut !== targetPort) return false;
|
|
1343
|
+
return portBinding(targetTask, targetPort) !== null;
|
|
1344
|
+
}
|
|
1345
|
+
function sourcePortOf(node) {
|
|
1346
|
+
switch (node.kind) {
|
|
1347
|
+
case "task":
|
|
1348
|
+
return taskPorts(node.task).output;
|
|
1349
|
+
case "prompt-source":
|
|
1350
|
+
return "text";
|
|
1351
|
+
case "image-source":
|
|
1352
|
+
return "image";
|
|
1353
|
+
case "input":
|
|
1354
|
+
return node.type;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function wireLegality(source, target, port) {
|
|
1358
|
+
if (source.id === target.id) return { ok: false, code: "cycle", message: `node "${source.id}" cannot wire into itself` };
|
|
1359
|
+
const produced = sourcePortOf(source);
|
|
1360
|
+
if (produced !== port) {
|
|
1361
|
+
return { ok: false, code: "invalid_port", message: `node "${source.id}" produces ${produced}, not ${port}` };
|
|
1362
|
+
}
|
|
1363
|
+
if (target.kind !== "task") {
|
|
1364
|
+
return { ok: false, code: "invalid_port", message: `node "${target.id}" (${target.kind}) has no inputs` };
|
|
1365
|
+
}
|
|
1366
|
+
if (!canConnect(produced, target.task, port)) {
|
|
1367
|
+
return { ok: false, code: "invalid_port", message: `a ${port} wire into "${target.id}" (${target.task}) is not supported` };
|
|
1368
|
+
}
|
|
1369
|
+
if (source.kind === "input" && source.cardinality === "many" && portBinding(target.task, port)?.wrap !== "array") {
|
|
1370
|
+
return {
|
|
1371
|
+
ok: false,
|
|
1372
|
+
code: "invalid_port",
|
|
1373
|
+
message: `list input "${source.id}" needs an array port; "${target.id}" (${target.task}) takes one ${port}`
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
if (target.task === "video-enhance" && port === "video") {
|
|
1377
|
+
if (source.kind !== "task") {
|
|
1378
|
+
return {
|
|
1379
|
+
ok: false,
|
|
1380
|
+
code: "invalid_port",
|
|
1381
|
+
message: `"${target.id}" (video-enhance) finishes a FLUX.3 DRAFT node's take from its draft cache; an uploaded or external video has no cache and cannot be enhanced, only re-rendered.`
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const model = source.model ? tryGetModel(source.model) : void 0;
|
|
1385
|
+
if (source.model && model && !model.capabilities?.emitsDraftCache) {
|
|
1386
|
+
return {
|
|
1387
|
+
ok: false,
|
|
1388
|
+
code: "invalid_port",
|
|
1389
|
+
message: `"${source.id}" (${source.model}) produces no draft cache, so "${target.id}" (video-enhance) cannot finish it. Wire a FLUX.3 draft node instead, or re-render at the target quality directly.`
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return { ok: true };
|
|
1394
|
+
}
|
|
1395
|
+
function documentTopologicalOrder(doc) {
|
|
1396
|
+
return kahnOrder(
|
|
1397
|
+
doc.nodes.map((n) => n.id),
|
|
1398
|
+
doc.edges.map((e) => [e.source, e.target])
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
function validateDocument(doc) {
|
|
1402
|
+
if (doc.schema !== DOCUMENT_SCHEMA_VERSION) {
|
|
1403
|
+
return {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
code: "schema",
|
|
1406
|
+
message: `document schema ${doc.schema} is not the current ${DOCUMENT_SCHEMA_VERSION}; migrate it on read first`
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1410
|
+
for (const node of doc.nodes) {
|
|
1411
|
+
if (byId.has(node.id)) return { ok: false, code: "conflict", message: `duplicate node id "${node.id}"` };
|
|
1412
|
+
byId.set(node.id, node);
|
|
1413
|
+
}
|
|
1414
|
+
const inputNames = /* @__PURE__ */ new Set();
|
|
1415
|
+
for (const node of doc.nodes) {
|
|
1416
|
+
if (node.kind !== "input") continue;
|
|
1417
|
+
if (inputNames.has(node.name)) {
|
|
1418
|
+
return { ok: false, code: "conflict", message: `duplicate input name "${node.name}"` };
|
|
1419
|
+
}
|
|
1420
|
+
inputNames.add(node.name);
|
|
1421
|
+
}
|
|
1422
|
+
const edgeKeys = /* @__PURE__ */ new Set();
|
|
1423
|
+
const slots = /* @__PURE__ */ new Set();
|
|
1424
|
+
for (const e of doc.edges) {
|
|
1425
|
+
const key = edgeKey(e);
|
|
1426
|
+
if (edgeKeys.has(key)) return { ok: false, code: "conflict", message: `duplicate edge ${key}` };
|
|
1427
|
+
edgeKeys.add(key);
|
|
1428
|
+
const source = byId.get(e.source);
|
|
1429
|
+
const target = byId.get(e.target);
|
|
1430
|
+
if (!source) return { ok: false, code: "not_found", message: `edge source "${e.source}" does not exist` };
|
|
1431
|
+
if (!target) return { ok: false, code: "not_found", message: `edge target "${e.target}" does not exist` };
|
|
1432
|
+
const legal = wireLegality(source, target, e.port);
|
|
1433
|
+
if (!legal.ok) return legal;
|
|
1434
|
+
const slot = slotKey(e.target, e.port);
|
|
1435
|
+
if (slots.has(slot)) {
|
|
1436
|
+
return {
|
|
1437
|
+
ok: false,
|
|
1438
|
+
code: "cardinality",
|
|
1439
|
+
message: `node "${e.target}" accepts one ${e.port} wire, got more`
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
slots.add(slot);
|
|
1443
|
+
}
|
|
1444
|
+
if (documentTopologicalOrder(doc) === null) {
|
|
1445
|
+
return { ok: false, code: "cycle", message: `document "${doc.id}" has a cycle in its wires` };
|
|
1446
|
+
}
|
|
1447
|
+
for (const id of Object.keys(doc.layout)) {
|
|
1448
|
+
if (!byId.has(id)) return { ok: false, code: "not_found", message: `layout entry for unknown node "${id}"` };
|
|
1449
|
+
}
|
|
1450
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
1451
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1452
|
+
for (const g of doc.groups) {
|
|
1453
|
+
if (groupIds.has(g.id)) return { ok: false, code: "conflict", message: `duplicate group id "${g.id}"` };
|
|
1454
|
+
groupIds.add(g.id);
|
|
1455
|
+
for (const id of g.nodeIds) {
|
|
1456
|
+
if (!byId.has(id)) return { ok: false, code: "not_found", message: `group "${g.id}" references unknown node "${id}"` };
|
|
1457
|
+
const other = grouped.get(id);
|
|
1458
|
+
if (other !== void 0) {
|
|
1459
|
+
return {
|
|
1460
|
+
ok: false,
|
|
1461
|
+
code: "conflict",
|
|
1462
|
+
message: `node "${id}" is in groups "${other}" and "${g.id}"; a node belongs to at most one group`
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
grouped.set(id, g.id);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return { ok: true };
|
|
1469
|
+
}
|
|
1470
|
+
var Index = z4.number().int().nonnegative();
|
|
1471
|
+
var TaskNodePropsSchema = z4.object({
|
|
1472
|
+
task: z4.enum(MODEL_TASKS),
|
|
1473
|
+
model: ModelIdSchema,
|
|
1474
|
+
prompt: PromptTextSchema,
|
|
1475
|
+
label: LabelSchema.nullable()
|
|
1476
|
+
}).partial().strict();
|
|
1477
|
+
var PromptSourcePropsSchema = z4.object({
|
|
1478
|
+
text: PromptTextSchema,
|
|
1479
|
+
label: LabelSchema.nullable()
|
|
1480
|
+
}).partial().strict();
|
|
1481
|
+
var ImageSourcePropsSchema = z4.object({
|
|
1482
|
+
imageUrl: UrlSchema,
|
|
1483
|
+
assetId: AssetIdSchema.nullable(),
|
|
1484
|
+
label: LabelSchema.nullable()
|
|
1485
|
+
}).partial().strict();
|
|
1486
|
+
var InputNodePropsSchema = z4.object({
|
|
1487
|
+
name: InputNameSchema,
|
|
1488
|
+
type: InputTypeSchema,
|
|
1489
|
+
required: z4.boolean(),
|
|
1490
|
+
cardinality: z4.enum(["one", "many"]).nullable(),
|
|
1491
|
+
default: JsonNonNullValueSchema.nullable(),
|
|
1492
|
+
label: LabelSchema.nullable(),
|
|
1493
|
+
description: DescriptionSchema.nullable()
|
|
1494
|
+
}).partial().strict();
|
|
1495
|
+
var GroupPropsSchema = z4.object({
|
|
1496
|
+
label: LabelSchema.nullable(),
|
|
1497
|
+
nodeIds: z4.array(IdSchema).min(1)
|
|
1498
|
+
}).partial().strict();
|
|
1499
|
+
var DocumentPropsSchema = z4.object({
|
|
1500
|
+
label: LabelSchema
|
|
1501
|
+
}).partial().strict();
|
|
1502
|
+
var AddNodeOpSchema = z4.object({
|
|
1503
|
+
type: z4.literal("addNode"),
|
|
1504
|
+
node: DocNodeSchema,
|
|
1505
|
+
/** Explicit position; omit to leave the node unplaced (auto-layout, D7). */
|
|
1506
|
+
position: PositionSchema.optional(),
|
|
1507
|
+
/** Insertion index in `nodes`; omit to append. Inverses use it for exactness. */
|
|
1508
|
+
index: Index.optional()
|
|
1509
|
+
});
|
|
1510
|
+
var RemoveNodeOpSchema = z4.object({
|
|
1511
|
+
type: z4.literal("removeNode"),
|
|
1512
|
+
id: IdSchema
|
|
1513
|
+
});
|
|
1514
|
+
var SetNodePropsOpSchema = z4.discriminatedUnion("kind", [
|
|
1515
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("task"), id: IdSchema, props: TaskNodePropsSchema }),
|
|
1516
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("prompt-source"), id: IdSchema, props: PromptSourcePropsSchema }),
|
|
1517
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("image-source"), id: IdSchema, props: ImageSourcePropsSchema }),
|
|
1518
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("input"), id: IdSchema, props: InputNodePropsSchema })
|
|
1519
|
+
]);
|
|
1520
|
+
var SetNodeSettingOpSchema = z4.object({
|
|
1521
|
+
type: z4.literal("setNodeSetting"),
|
|
1522
|
+
id: IdSchema,
|
|
1523
|
+
key: KeySchema,
|
|
1524
|
+
/** null unsets the knob. */
|
|
1525
|
+
value: JsonNonNullValueSchema.nullable()
|
|
1526
|
+
});
|
|
1527
|
+
var SetBakedInputOpSchema = z4.object({
|
|
1528
|
+
type: z4.literal("setBakedInput"),
|
|
1529
|
+
id: IdSchema,
|
|
1530
|
+
key: KeySchema,
|
|
1531
|
+
/** A literal only (a dependency must be a wire); null removes the entry. */
|
|
1532
|
+
binding: LiteralBindingSchema.nullable()
|
|
1533
|
+
});
|
|
1534
|
+
var ConnectOpSchema = z4.object({
|
|
1535
|
+
type: z4.literal("connect"),
|
|
1536
|
+
source: IdSchema,
|
|
1537
|
+
target: IdSchema,
|
|
1538
|
+
port: PortTypeSchema,
|
|
1539
|
+
index: Index.optional()
|
|
1540
|
+
});
|
|
1541
|
+
var DisconnectOpSchema = z4.object({
|
|
1542
|
+
type: z4.literal("disconnect"),
|
|
1543
|
+
source: IdSchema,
|
|
1544
|
+
target: IdSchema,
|
|
1545
|
+
port: PortTypeSchema
|
|
1546
|
+
});
|
|
1547
|
+
var ExposeAsInputOpSchema = z4.object({
|
|
1548
|
+
type: z4.literal("exposeAsInput"),
|
|
1549
|
+
/** A prompt-source or image-source node; it becomes an input node IN PLACE
|
|
1550
|
+
* (same id, same produced port type), so its wires survive. */
|
|
1551
|
+
id: IdSchema,
|
|
1552
|
+
name: InputNameSchema,
|
|
1553
|
+
required: z4.boolean().optional(),
|
|
1554
|
+
/** undefined keeps the source's label, null unsets it. */
|
|
1555
|
+
label: LabelSchema.nullable().optional(),
|
|
1556
|
+
description: DescriptionSchema.nullable().optional(),
|
|
1557
|
+
/** undefined derives the default from the source's literal (when it is a
|
|
1558
|
+
* usable value), null declares no default. */
|
|
1559
|
+
default: JsonNonNullValueSchema.nullable().optional()
|
|
1560
|
+
});
|
|
1561
|
+
var FreezeInputOpSchema = z4.object({
|
|
1562
|
+
type: z4.literal("freezeInput"),
|
|
1563
|
+
/** A text or image input node; it becomes a prompt-source / image-source IN
|
|
1564
|
+
* PLACE carrying `value` (a chosen run's resolved value, or any literal). */
|
|
1565
|
+
id: IdSchema,
|
|
1566
|
+
value: PromptTextSchema,
|
|
1567
|
+
assetId: AssetIdSchema.optional(),
|
|
1568
|
+
/** undefined keeps the input's label, null unsets it. */
|
|
1569
|
+
label: LabelSchema.nullable().optional()
|
|
1570
|
+
});
|
|
1571
|
+
var SetActiveGenerationOpSchema = z4.object({
|
|
1572
|
+
type: z4.literal("setActiveGeneration"),
|
|
1573
|
+
id: IdSchema,
|
|
1574
|
+
/** null = latest (D4). */
|
|
1575
|
+
generationId: IdSchema.nullable()
|
|
1576
|
+
});
|
|
1577
|
+
var SetLayoutOpSchema = z4.object({
|
|
1578
|
+
type: z4.literal("setLayout"),
|
|
1579
|
+
id: IdSchema,
|
|
1580
|
+
/** null unplaces the node (it becomes an auto-layout target again). */
|
|
1581
|
+
position: PositionSchema.nullable()
|
|
1582
|
+
});
|
|
1583
|
+
var GroupOpSchema = z4.object({
|
|
1584
|
+
type: z4.literal("group"),
|
|
1585
|
+
id: IdSchema,
|
|
1586
|
+
label: LabelSchema.optional(),
|
|
1587
|
+
nodeIds: z4.array(IdSchema).min(1),
|
|
1588
|
+
index: Index.optional()
|
|
1589
|
+
});
|
|
1590
|
+
var UngroupOpSchema = z4.object({
|
|
1591
|
+
type: z4.literal("ungroup"),
|
|
1592
|
+
id: IdSchema
|
|
1593
|
+
});
|
|
1594
|
+
var SetGroupPropsOpSchema = z4.object({
|
|
1595
|
+
type: z4.literal("setGroupProps"),
|
|
1596
|
+
id: IdSchema,
|
|
1597
|
+
props: GroupPropsSchema
|
|
1598
|
+
});
|
|
1599
|
+
var SetDocumentPropsOpSchema = z4.object({
|
|
1600
|
+
type: z4.literal("setDocumentProps"),
|
|
1601
|
+
props: DocumentPropsSchema
|
|
1602
|
+
});
|
|
1603
|
+
var OpSchema = z4.discriminatedUnion("type", [
|
|
1604
|
+
AddNodeOpSchema,
|
|
1605
|
+
RemoveNodeOpSchema,
|
|
1606
|
+
SetNodePropsOpSchema,
|
|
1607
|
+
SetNodeSettingOpSchema,
|
|
1608
|
+
SetBakedInputOpSchema,
|
|
1609
|
+
ConnectOpSchema,
|
|
1610
|
+
DisconnectOpSchema,
|
|
1611
|
+
ExposeAsInputOpSchema,
|
|
1612
|
+
FreezeInputOpSchema,
|
|
1613
|
+
SetActiveGenerationOpSchema,
|
|
1614
|
+
SetLayoutOpSchema,
|
|
1615
|
+
GroupOpSchema,
|
|
1616
|
+
UngroupOpSchema,
|
|
1617
|
+
SetGroupPropsOpSchema,
|
|
1618
|
+
SetDocumentPropsOpSchema
|
|
1619
|
+
]);
|
|
1620
|
+
var MAX_PATCH_OPS = 500;
|
|
1621
|
+
var PatchSchema = z4.object({
|
|
1622
|
+
baseRevision: z4.number().int().nonnegative(),
|
|
1623
|
+
ops: z4.array(OpSchema).min(1).max(MAX_PATCH_OPS),
|
|
1624
|
+
idempotencyKey: z4.string().min(1).max(128).optional()
|
|
1625
|
+
});
|
|
1626
|
+
function opTouches(doc, op) {
|
|
1627
|
+
const out = /* @__PURE__ */ new Set();
|
|
1628
|
+
switch (op.type) {
|
|
1629
|
+
case "addNode":
|
|
1630
|
+
out.add(op.node.id);
|
|
1631
|
+
break;
|
|
1632
|
+
case "removeNode": {
|
|
1633
|
+
out.add(op.id);
|
|
1634
|
+
for (const e of doc.edges) {
|
|
1635
|
+
if (e.source === op.id) out.add(e.target);
|
|
1636
|
+
else if (e.target === op.id) out.add(e.source);
|
|
1637
|
+
}
|
|
1638
|
+
for (const g of doc.groups) if (g.nodeIds.includes(op.id)) for (const id of g.nodeIds) out.add(id);
|
|
1639
|
+
break;
|
|
1640
|
+
}
|
|
1641
|
+
case "connect":
|
|
1642
|
+
case "disconnect":
|
|
1643
|
+
out.add(op.source);
|
|
1644
|
+
out.add(op.target);
|
|
1645
|
+
break;
|
|
1646
|
+
case "group":
|
|
1647
|
+
for (const id of op.nodeIds) out.add(id);
|
|
1648
|
+
break;
|
|
1649
|
+
case "ungroup":
|
|
1650
|
+
case "setGroupProps": {
|
|
1651
|
+
const g = doc.groups.find((x) => x.id === op.id);
|
|
1652
|
+
for (const id of g?.nodeIds ?? []) out.add(id);
|
|
1653
|
+
if (op.type === "setGroupProps") for (const id of op.props.nodeIds ?? []) out.add(id);
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
case "setDocumentProps":
|
|
1657
|
+
break;
|
|
1658
|
+
default:
|
|
1659
|
+
out.add(op.id);
|
|
1660
|
+
}
|
|
1661
|
+
return [...out];
|
|
1662
|
+
}
|
|
1663
|
+
var RECIPE_EFFECT_TYPES = ["createCharacter", "addCharacterReference", "createBrand", "addBrandReference"];
|
|
1664
|
+
var EffectValueSourceSchema = z5.discriminatedUnion("kind", [
|
|
1665
|
+
z5.object({ kind: z5.literal("literal"), value: JsonNonNullValueSchema }),
|
|
1666
|
+
/** Reads a value out of the recipe run's own input params (the same
|
|
1667
|
+
* params object `studio_run_published` accepts). */
|
|
1668
|
+
z5.object({ kind: z5.literal("param"), name: z5.string().min(1) }),
|
|
1669
|
+
/** Reads a value off a completed node's resolvable output (the same map
|
|
1670
|
+
* `collectOutputs` builds: `url`, `assetId`, ...). For a `forEachNode`
|
|
1671
|
+
* effect, `node` MUST equal that effect's own `forEachNode` (checked by
|
|
1672
|
+
* `validateRecipeEffects`); for a non-iterating effect, `node` names any
|
|
1673
|
+
* node in the workflow. */
|
|
1674
|
+
z5.object({ kind: z5.literal("nodeOutput"), node: IdSchema, path: z5.string().min(1) })
|
|
1675
|
+
]);
|
|
1676
|
+
var FieldsSchema = z5.record(z5.string().min(1), EffectValueSourceSchema);
|
|
1677
|
+
var RecipeEffectSchema = z5.object({
|
|
1678
|
+
type: z5.enum(RECIPE_EFFECT_TYPES),
|
|
1679
|
+
/** "For each output of node X, apply this effect." Absent = the effect
|
|
1680
|
+
* runs exactly once. Present = once per iteration of that node's output
|
|
1681
|
+
* (today: once, since a node run produces one output; the field exists so
|
|
1682
|
+
* a future batch-output node needs no schema change here). */
|
|
1683
|
+
forEachNode: IdSchema.optional(),
|
|
1684
|
+
/** Field name -> how to fill it. The required/allowed fields per `type`
|
|
1685
|
+
* are checked by `validateRecipeEffects`, not by this schema (a
|
|
1686
|
+
* discriminated-union-of-fields would duplicate the repository input
|
|
1687
|
+
* shapes here; the validator is the one place that has to agree with
|
|
1688
|
+
* them). */
|
|
1689
|
+
fields: FieldsSchema
|
|
1690
|
+
});
|
|
1691
|
+
var RecipeEffectsSchema = z5.array(RecipeEffectSchema).max(50);
|
|
1692
|
+
function taskModality(task) {
|
|
1693
|
+
const out = taskPorts(task).output;
|
|
1694
|
+
switch (out) {
|
|
1695
|
+
case "mesh3d":
|
|
1696
|
+
return "3d";
|
|
1697
|
+
case "video":
|
|
1698
|
+
return "video";
|
|
1699
|
+
case "audio":
|
|
1700
|
+
return "audio";
|
|
1701
|
+
case "text":
|
|
1702
|
+
case "json":
|
|
1703
|
+
return "text";
|
|
1704
|
+
case "image":
|
|
1705
|
+
case "mask":
|
|
1706
|
+
return "image";
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
function lit(value) {
|
|
1710
|
+
return { kind: "literal", value: toJsonValue(value, "setting") };
|
|
1711
|
+
}
|
|
1712
|
+
function isFiniteNumber(v) {
|
|
1713
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
1714
|
+
}
|
|
1715
|
+
function settingsBindings(task, settings) {
|
|
1716
|
+
if (!task || !settings || Object.keys(settings).length === 0) return {};
|
|
1717
|
+
const modality = taskModality(task);
|
|
1718
|
+
switch (modality) {
|
|
1719
|
+
case "image": {
|
|
1720
|
+
const out = {};
|
|
1721
|
+
if (typeof settings.aspectRatio === "string") out.aspectRatio = lit(settings.aspectRatio);
|
|
1722
|
+
if (typeof settings.resolution === "string") out.imageSize = lit(settings.resolution);
|
|
1723
|
+
if (isFiniteNumber(settings.seed)) out.seed = lit(settings.seed);
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
case "video": {
|
|
1727
|
+
const options = {};
|
|
1728
|
+
if (typeof settings.aspectRatio === "string") options.aspectRatio = settings.aspectRatio;
|
|
1729
|
+
if (typeof settings.resolution === "string") options.resolution = settings.resolution;
|
|
1730
|
+
if (isFiniteNumber(settings.duration)) options.durationSeconds = settings.duration;
|
|
1731
|
+
return Object.keys(options).length > 0 ? { options: lit(options) } : {};
|
|
1732
|
+
}
|
|
1733
|
+
case "audio": {
|
|
1734
|
+
const options = {};
|
|
1735
|
+
if (isFiniteNumber(settings.duration)) options.durationSeconds = settings.duration;
|
|
1736
|
+
return Object.keys(options).length > 0 ? { options: lit(options) } : {};
|
|
1737
|
+
}
|
|
1738
|
+
default:
|
|
1739
|
+
return {};
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
var TASK_TO_KIND = {
|
|
1743
|
+
// image
|
|
1744
|
+
"text-to-image": "generate_image",
|
|
1745
|
+
"image-to-image": "generate_image",
|
|
1746
|
+
"image-edit": "generate_image",
|
|
1747
|
+
// virtual try-on (E3): two named image inputs (garment + person) ride the
|
|
1748
|
+
// generate_image job's `inputImages` array; the fal provider routes the task
|
|
1749
|
+
// to the FASHN adapter and the result persists through the sync image branch.
|
|
1750
|
+
"virtual-try-on": "generate_image",
|
|
1751
|
+
// 3D
|
|
1752
|
+
"text-to-3d": "generate_3d",
|
|
1753
|
+
"image-to-3d": "generate_3d",
|
|
1754
|
+
"multiview-to-3d": "generate_3d",
|
|
1755
|
+
remesh: "remesh_3d",
|
|
1756
|
+
texture: "texture_3d",
|
|
1757
|
+
// video (async via fal queue)
|
|
1758
|
+
"text-to-video": "generate_video",
|
|
1759
|
+
"image-to-video": "generate_video",
|
|
1760
|
+
// video-to-video (character replace via Wan 2.2 Animate): rides the same
|
|
1761
|
+
// generate_video job; the fal provider routes the task to the replace endpoint.
|
|
1762
|
+
"video-to-video": "generate_video",
|
|
1763
|
+
// video-enhance (FLUX.3 draft-enhance): the same generate_video job; the fal
|
|
1764
|
+
// provider sends the upstream draft's cache bundle instead of frames.
|
|
1765
|
+
"video-enhance": "generate_video",
|
|
1766
|
+
// audio + voice
|
|
1767
|
+
"text-to-audio": "generate_audio",
|
|
1768
|
+
"text-to-speech": "synthesize_speech",
|
|
1769
|
+
"speech-to-text": "transcribe",
|
|
1770
|
+
// vision (text/json output, no binary asset)
|
|
1771
|
+
"image-to-text": "analyze_image",
|
|
1772
|
+
"image-to-json": "analyze_image",
|
|
1773
|
+
// utility (new Image asset off the source)
|
|
1774
|
+
upscale: "transform_image",
|
|
1775
|
+
"background-remove": "transform_image",
|
|
1776
|
+
// No asset/job path yet: text-generation is the LLM director (a sync text
|
|
1777
|
+
// service, no Job/Asset); speech-to-speech and segmentation have no provider
|
|
1778
|
+
// wiring. These are not authorable on the canvas (isBuildableTask is false).
|
|
1779
|
+
"text-generation": null,
|
|
1780
|
+
"speech-to-speech": null,
|
|
1781
|
+
segmentation: null,
|
|
1782
|
+
// Judge (P3 J2): null here FOREVER, and that is not "unrunnable". A judge
|
|
1783
|
+
// node runs as a human gate, never as a queued Job: it parks the run on a
|
|
1784
|
+
// person's decision instead of dispatching to a provider. Runnability is
|
|
1785
|
+
// therefore `taskExecution` below, not this table; giving judge a JobKind
|
|
1786
|
+
// would enqueue a pg-boss job no handler consumes.
|
|
1787
|
+
judge: null
|
|
1788
|
+
};
|
|
1789
|
+
function taskExecution(task) {
|
|
1790
|
+
if (task === "judge") return "gate";
|
|
1791
|
+
return TASK_TO_KIND[task] === null ? null : "job";
|
|
1792
|
+
}
|
|
1793
|
+
function paramTypeForInput(type) {
|
|
1794
|
+
return type === "json" ? "json" : "string";
|
|
1795
|
+
}
|
|
1796
|
+
function isUsableImageUrl(url) {
|
|
1797
|
+
return url.length > 0 && !url.startsWith("data:");
|
|
1798
|
+
}
|
|
1799
|
+
function documentToDefinition(doc, opts = {}) {
|
|
1800
|
+
const integrity = validateDocument(doc);
|
|
1801
|
+
if (!integrity.ok) return { ok: false, issues: [{ code: "integrity", message: integrity.message }] };
|
|
1802
|
+
const issues = [];
|
|
1803
|
+
const byId = new Map(doc.nodes.map((n) => [n.id, n]));
|
|
1804
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const e of doc.edges) {
|
|
1806
|
+
const list = incoming.get(e.target);
|
|
1807
|
+
if (list) list.push(e);
|
|
1808
|
+
else incoming.set(e.target, [e]);
|
|
1809
|
+
}
|
|
1810
|
+
const params = doc.nodes.filter((n) => n.kind === "input").map((n) => ({
|
|
1811
|
+
name: n.name,
|
|
1812
|
+
type: n.cardinality === "many" ? "json" : paramTypeForInput(n.type),
|
|
1813
|
+
required: n.required,
|
|
1814
|
+
...n.label !== void 0 ? { label: n.label } : {},
|
|
1815
|
+
...n.description !== void 0 ? { description: n.description } : {},
|
|
1816
|
+
inputType: n.type,
|
|
1817
|
+
...n.cardinality === "many" ? { cardinality: "many" } : {},
|
|
1818
|
+
...n.default !== void 0 ? { default: n.default } : {}
|
|
1819
|
+
}));
|
|
1820
|
+
const nodes = [];
|
|
1821
|
+
for (const node of doc.nodes) {
|
|
1822
|
+
if (node.kind !== "task") continue;
|
|
1823
|
+
const { task } = node;
|
|
1824
|
+
if (taskExecution(task) === null) {
|
|
1825
|
+
issues.push({ code: "not_runnable", nodeId: node.id, message: `Node "${node.id}" (${task}) is not runnable yet.` });
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
if (node.model.length === 0) {
|
|
1829
|
+
issues.push({ code: "no_model", nodeId: node.id, message: `Node "${node.id}" needs a model.` });
|
|
1830
|
+
}
|
|
1831
|
+
const inputs = {};
|
|
1832
|
+
const wires = incoming.get(node.id) ?? [];
|
|
1833
|
+
const textWire = wires.find((e) => e.port === "text");
|
|
1834
|
+
let promptMissing = false;
|
|
1835
|
+
if (taskNeedsPrompt(task)) {
|
|
1836
|
+
const key = promptInputKey(task);
|
|
1837
|
+
const source = textWire ? byId.get(textWire.source) : void 0;
|
|
1838
|
+
if (source?.kind === "prompt-source") {
|
|
1839
|
+
const text = source.text.trim();
|
|
1840
|
+
if (text.length > 0) inputs[key] = { kind: "literal", value: text };
|
|
1841
|
+
} else if (source?.kind === "input") {
|
|
1842
|
+
inputs[key] = { kind: "param", name: source.name };
|
|
1843
|
+
} else if (source?.kind === "task") {
|
|
1844
|
+
inputs[key] = { kind: "ref", node: source.id, path: portBinding(task, "text").path };
|
|
1845
|
+
} else {
|
|
1846
|
+
const text = node.prompt.trim();
|
|
1847
|
+
if (text.length > 0) inputs[key] = { kind: "literal", value: text };
|
|
1848
|
+
}
|
|
1849
|
+
if (!(key in inputs) && taskRequiresPrompt(task)) {
|
|
1850
|
+
promptMissing = true;
|
|
1851
|
+
issues.push({ code: "prompt_required", nodeId: node.id, message: `Node "${node.id}" (${task}) needs a prompt.` });
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
for (const e of wires) {
|
|
1855
|
+
if (e.port === "text") continue;
|
|
1856
|
+
const b = portBinding(task, e.port);
|
|
1857
|
+
const source = byId.get(e.source);
|
|
1858
|
+
if (source.kind === "image-source") {
|
|
1859
|
+
const url = source.imageUrl.trim();
|
|
1860
|
+
if (!isUsableImageUrl(url)) {
|
|
1861
|
+
issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
inputs[b.key] = { kind: "literal", value: b.wrap === "array" ? [url] : url };
|
|
1865
|
+
} else if (source.kind === "input") {
|
|
1866
|
+
inputs[b.key] = b.wrap === "array" && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
|
|
1867
|
+
} else if (source.kind === "task") {
|
|
1868
|
+
inputs[b.key] = b.wrap === "array" ? { kind: "ref", node: source.id, path: b.path, wrap: "array" } : { kind: "ref", node: source.id, path: b.path };
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
for (const [key, binding] of Object.entries(node.bakedInputs)) if (!(key in inputs)) inputs[key] = binding;
|
|
1872
|
+
if (Object.keys(inputs).length === 0 && !promptMissing) {
|
|
1873
|
+
issues.push({ code: "no_inputs", nodeId: node.id, message: `Node "${node.id}" (${task}) needs an input: wire in an upstream asset or type a prompt.` });
|
|
1874
|
+
}
|
|
1875
|
+
Object.assign(inputs, settingsBindings(task, node.settings));
|
|
1876
|
+
nodes.push({ id: node.id, task, model: node.model, inputs });
|
|
1877
|
+
}
|
|
1878
|
+
if (nodes.length === 0 && issues.length === 0) {
|
|
1879
|
+
issues.push({ code: "no_task_nodes", message: "Add at least one model node." });
|
|
1880
|
+
}
|
|
1881
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
1882
|
+
const label = opts.label ?? doc.label;
|
|
1883
|
+
return {
|
|
1884
|
+
ok: true,
|
|
1885
|
+
definition: {
|
|
1886
|
+
version: { schema: 1 },
|
|
1887
|
+
id: opts.id ?? doc.id,
|
|
1888
|
+
...label.length > 0 ? { label } : {},
|
|
1889
|
+
params,
|
|
1890
|
+
nodes
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// src/characterSheet.ts
|
|
1896
|
+
var IDENTITY_INSTRUCTION = "This is the same person, same face, same identity as the reference image: keep the facial structure, features, hair, and proportions identical. Do not invent a new face.";
|
|
1897
|
+
var NEUTRAL_STUDIO = "Plain light-gray seamless studio background, no props, no text, no watermark, photorealistic, sharp focus.";
|
|
1898
|
+
var WARDROBE_STANDARD_PHRASE = "plain heather-gray crew-neck t-shirt";
|
|
1899
|
+
var WARDROBE_INSTRUCTION = `Wardrobe: the character is wearing a ${WARDROBE_STANDARD_PHRASE}, unbranded, with no logos, graphics, prints, or patterns.`;
|
|
1900
|
+
var FULL_BODY_WARDROBE_INSTRUCTION = `Wardrobe: the character is wearing a ${WARDROBE_STANDARD_PHRASE} and plain dark straight-leg trousers, unbranded, with no logos, graphics, prints, or patterns.`;
|
|
1901
|
+
function wardrobeInstructionForCategory(category) {
|
|
1902
|
+
return category === "full-body" ? FULL_BODY_WARDROBE_INSTRUCTION : WARDROBE_INSTRUCTION;
|
|
1903
|
+
}
|
|
1904
|
+
function identityAnchor(description) {
|
|
1905
|
+
return `Character identity (keep identical in every view): ${description.trim()}`;
|
|
1906
|
+
}
|
|
1907
|
+
function seedFacePrompt(description) {
|
|
1908
|
+
return [
|
|
1909
|
+
identityAnchor(description),
|
|
1910
|
+
"Generate a clean front-facing reference portrait of this character: head-and-shoulders framing, neutral expression, looking straight at the camera. Square 1:1 crop.",
|
|
1911
|
+
"Even, soft, neutral studio lighting.",
|
|
1912
|
+
wardrobeInstructionForCategory("face"),
|
|
1913
|
+
NEUTRAL_STUDIO,
|
|
1914
|
+
IDENTITY_INSTRUCTION
|
|
1915
|
+
].join("\n\n");
|
|
1916
|
+
}
|
|
1917
|
+
var VIEW_INSTRUCTION = {
|
|
1918
|
+
"three-quarter": "Three-quarter (45 degree) view: turn the head and upper body slightly to one side. Chest-up framing, square 1:1 crop. Even neutral studio lighting.",
|
|
1919
|
+
profile: "Full side profile (90 degree) view. Chest-up framing, square 1:1 crop. Even neutral studio lighting.",
|
|
1920
|
+
"full-body": "Full-body head-to-toe view: the character standing in a relaxed neutral pose, facing forward. 3:4 portrait crop with the whole figure in frame. Even neutral studio lighting.",
|
|
1921
|
+
"close-up": "Tight front-facing close-up of the face, capturing fine facial detail. Square 1:1 crop. Even neutral studio lighting.",
|
|
1922
|
+
"lighting-warm": "Front-facing head-and-shoulders portrait, square 1:1 crop, under warm golden-hour key lighting.",
|
|
1923
|
+
"lighting-cool": "Front-facing head-and-shoulders portrait, square 1:1 crop, under cool blue-toned key lighting."
|
|
1924
|
+
};
|
|
1925
|
+
function turnaroundViewPrompt(view, seedDescription) {
|
|
1926
|
+
return [
|
|
1927
|
+
identityAnchor(seedDescription),
|
|
1928
|
+
VIEW_INSTRUCTION[view],
|
|
1929
|
+
wardrobeInstructionForCategory(view),
|
|
1930
|
+
NEUTRAL_STUDIO,
|
|
1931
|
+
IDENTITY_INSTRUCTION
|
|
1932
|
+
].join("\n\n");
|
|
1933
|
+
}
|
|
1934
|
+
var CHARACTER_SHEET_NODES = [
|
|
1935
|
+
{ nodeId: "seed", param: "seedPrompt", category: "face", label: "Seed face" },
|
|
1936
|
+
{ nodeId: "view-three-quarter", param: "threeQuarterPrompt", category: "three-quarter", label: "Three-quarter", view: "three-quarter" },
|
|
1937
|
+
{ nodeId: "view-profile", param: "profilePrompt", category: "profile", label: "Profile", view: "profile" },
|
|
1938
|
+
{ nodeId: "view-full-body", param: "fullBodyPrompt", category: "full-body", label: "Full body", view: "full-body" },
|
|
1939
|
+
{ nodeId: "view-close-up", param: "closeUpPrompt", category: "close-up", label: "Close-up", view: "close-up" },
|
|
1940
|
+
{ nodeId: "view-lighting-warm", param: "lightingWarmPrompt", category: "lighting-warm", label: "Warm lighting", view: "lighting-warm" },
|
|
1941
|
+
{ nodeId: "view-lighting-cool", param: "lightingCoolPrompt", category: "lighting-cool", label: "Cool lighting", view: "lighting-cool" }
|
|
1942
|
+
];
|
|
1943
|
+
var MINT_REFERENCE_CATEGORIES = Object.fromEntries(
|
|
1944
|
+
CHARACTER_SHEET_NODES.map((n) => [n.nodeId, n.category])
|
|
1945
|
+
);
|
|
1946
|
+
function categoryForNode(nodeId) {
|
|
1947
|
+
return MINT_REFERENCE_CATEGORIES[nodeId] ?? null;
|
|
1948
|
+
}
|
|
1949
|
+
function buildCharacterSheetParams(description) {
|
|
1950
|
+
const params = {};
|
|
1951
|
+
for (const node of CHARACTER_SHEET_NODES) {
|
|
1952
|
+
params[node.param] = node.view ? turnaroundViewPrompt(node.view, description) : seedFacePrompt(description);
|
|
1953
|
+
}
|
|
1954
|
+
return params;
|
|
1955
|
+
}
|
|
1956
|
+
var CHARACTER_SHEET_ID = "character-sheet";
|
|
1957
|
+
var SEED_MODEL = "fal/nano-banana-2";
|
|
1958
|
+
var VIEW_MODEL = "fal/nano-banana-2-edit";
|
|
1959
|
+
function viewNode(n, aspectRatio) {
|
|
1960
|
+
return {
|
|
1961
|
+
id: n.nodeId,
|
|
1962
|
+
task: "image-edit",
|
|
1963
|
+
model: VIEW_MODEL,
|
|
1964
|
+
inputs: {
|
|
1965
|
+
prompt: { kind: "param", name: n.param },
|
|
1966
|
+
inputImages: { kind: "ref", node: "seed", path: "url", wrap: "array" },
|
|
1967
|
+
aspectRatio: { kind: "literal", value: aspectRatio }
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
var CHARACTER_SHEET_DEFINITION = {
|
|
1972
|
+
version: { schema: 1 },
|
|
1973
|
+
id: CHARACTER_SHEET_ID,
|
|
1974
|
+
label: "Character Sheet (seed face -> turnaround)",
|
|
1975
|
+
params: CHARACTER_SHEET_NODES.map((n) => ({
|
|
1976
|
+
name: n.param,
|
|
1977
|
+
type: "string",
|
|
1978
|
+
required: true,
|
|
1979
|
+
label: `${n.label} prompt`
|
|
1980
|
+
})),
|
|
1981
|
+
nodes: [
|
|
1982
|
+
{
|
|
1983
|
+
id: "seed",
|
|
1984
|
+
task: "text-to-image",
|
|
1985
|
+
model: SEED_MODEL,
|
|
1986
|
+
inputs: {
|
|
1987
|
+
prompt: { kind: "param", name: "seedPrompt" },
|
|
1988
|
+
aspectRatio: { kind: "literal", value: "1:1" }
|
|
1989
|
+
}
|
|
1990
|
+
},
|
|
1991
|
+
...CHARACTER_SHEET_NODES.filter((n) => n.view !== void 0).map(
|
|
1992
|
+
(n) => viewNode(n, n.category === "full-body" ? "3:4" : "1:1")
|
|
1993
|
+
)
|
|
1994
|
+
]
|
|
1995
|
+
};
|
|
1996
|
+
function characterSheetDefinition(overrides) {
|
|
1997
|
+
const def = structuredClone(CHARACTER_SHEET_DEFINITION);
|
|
1998
|
+
if (overrides?.seedModel || overrides?.viewModel) {
|
|
1999
|
+
def.nodes = def.nodes.map(
|
|
2000
|
+
(node) => node.id === "seed" ? { ...node, model: overrides.seedModel ?? node.model } : { ...node, model: overrides.viewModel ?? node.model }
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
return def;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// src/media.ts
|
|
2007
|
+
import { readFile, stat } from "fs/promises";
|
|
2008
|
+
import { basename, resolve } from "path";
|
|
2009
|
+
import { fileURLToPath } from "url";
|
|
2010
|
+
var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
2011
|
+
function isHttpUrl(s) {
|
|
2012
|
+
return /^https?:\/\//i.test(s);
|
|
2013
|
+
}
|
|
2014
|
+
async function loadImageSource(source, client) {
|
|
2015
|
+
const trimmed = source.trim();
|
|
2016
|
+
if (trimmed.length === 0) throw new ToolInputError("source is empty: pass a local file path or an http(s) URL");
|
|
2017
|
+
if (isHttpUrl(trimmed)) {
|
|
2018
|
+
const file2 = await client.downloadUrl(trimmed);
|
|
2019
|
+
assertImage(file2, trimmed);
|
|
2020
|
+
return file2;
|
|
2021
|
+
}
|
|
2022
|
+
const path = trimmed.startsWith("file://") ? fileURLToPath(trimmed) : resolve(trimmed);
|
|
2023
|
+
let size;
|
|
2024
|
+
try {
|
|
2025
|
+
const s = await stat(path);
|
|
2026
|
+
if (!s.isFile()) throw new ToolInputError(`not a file: ${path}`);
|
|
2027
|
+
size = s.size;
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
if (err instanceof ToolInputError) throw err;
|
|
2030
|
+
throw new ToolInputError(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2031
|
+
}
|
|
2032
|
+
if (size > MAX_UPLOAD_BYTES) {
|
|
2033
|
+
throw new ToolInputError(`${path} is ${size} bytes, above the ${MAX_UPLOAD_BYTES} byte upload limit`);
|
|
2034
|
+
}
|
|
2035
|
+
const bytes = await readFile(path);
|
|
2036
|
+
const filename = basename(path);
|
|
2037
|
+
const file = { data: new Blob([bytes], { type: mimeForFilename(filename) }), filename };
|
|
2038
|
+
assertImage(file, path);
|
|
2039
|
+
return file;
|
|
2040
|
+
}
|
|
2041
|
+
function assertImage(file, source) {
|
|
2042
|
+
if (!file.data.type.startsWith("image/")) {
|
|
2043
|
+
throw new ToolInputError(
|
|
2044
|
+
`${source} does not look like an image (content type ${file.data.type || "unknown"}); the Studio only accepts image/* references`
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
if (file.data.size === 0) throw new ToolInputError(`${source} is empty (0 bytes)`);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/poll.ts
|
|
2051
|
+
var DEFAULT_TIMEOUT_MS = 6e5;
|
|
2052
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2053
|
+
async function pollUntil(what, fetchState, isDone, opts = {}) {
|
|
2054
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2055
|
+
const factor = opts.factor ?? 1.5;
|
|
2056
|
+
const maxDelay = opts.maxDelayMs ?? 15e3;
|
|
2057
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
2058
|
+
const now = opts.now ?? Date.now;
|
|
2059
|
+
const start = now();
|
|
2060
|
+
let delay = opts.initialDelayMs ?? 2e3;
|
|
2061
|
+
let last = await fetchState();
|
|
2062
|
+
if (isDone(last)) return last;
|
|
2063
|
+
for (; ; ) {
|
|
2064
|
+
const elapsed = now() - start;
|
|
2065
|
+
const remaining = timeoutMs - elapsed;
|
|
2066
|
+
if (remaining <= 0) throw new PollTimeoutError(what, timeoutMs, last);
|
|
2067
|
+
await sleep(Math.min(delay, remaining));
|
|
2068
|
+
last = await fetchState();
|
|
2069
|
+
if (isDone(last)) return last;
|
|
2070
|
+
delay = Math.min(maxDelay, Math.round(delay * factor));
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "canceled"]);
|
|
2074
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "partial"]);
|
|
2075
|
+
function isJobTerminal(res) {
|
|
2076
|
+
return TERMINAL_JOB_STATUSES.has(res.job.status);
|
|
2077
|
+
}
|
|
2078
|
+
function isRunTerminal(res) {
|
|
2079
|
+
return TERMINAL_RUN_STATUSES.has(res.run.status);
|
|
2080
|
+
}
|
|
2081
|
+
function isRunParked(res) {
|
|
2082
|
+
return res.run.status === "awaiting_decision";
|
|
2083
|
+
}
|
|
2084
|
+
function isRunSettledOrParked(res) {
|
|
2085
|
+
return isRunTerminal(res) || isRunParked(res);
|
|
2086
|
+
}
|
|
2087
|
+
function waitForJob(client, jobId, opts) {
|
|
2088
|
+
return pollUntil(`job ${jobId}`, () => client.getJob(jobId), isJobTerminal, opts);
|
|
2089
|
+
}
|
|
2090
|
+
function waitForRun(client, runId, opts) {
|
|
2091
|
+
return pollUntil(`workflow run ${runId}`, () => client.getRun(runId), isRunSettledOrParked, opts);
|
|
2092
|
+
}
|
|
2093
|
+
function runCostUsd(res) {
|
|
2094
|
+
return res.nodeRuns.reduce((sum, nr) => sum + (typeof nr.costUsd === "number" ? nr.costUsd : 0), 0);
|
|
2095
|
+
}
|
|
2096
|
+
function runFailureSummary(res) {
|
|
2097
|
+
const parts = res.nodeRuns.filter((nr) => nr.status === "failed").map((nr) => `${nr.nodeId}: ${nr.errorMessage ?? "failed without a message"}`);
|
|
2098
|
+
const runLevel = res.run.status === "succeeded" && typeof res.run.errorMessage === "string" ? res.run.errorMessage.trim() : "";
|
|
2099
|
+
if (runLevel.length > 0) parts.push(runLevel);
|
|
2100
|
+
return parts.length > 0 ? parts.join("; ") : null;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// src/recipes.ts
|
|
2104
|
+
var DEFAULT_SWAP_EDIT_MODEL = "fal/nano-banana-2-edit";
|
|
2105
|
+
var DEFAULT_SWAP_VIDEO_MODEL = "fal/seedance-pro-image-to-video";
|
|
2106
|
+
var KEYFRAME_SWAP_VIDEO_MODEL = "fal/kling-v2-5-image-to-video";
|
|
2107
|
+
var DEFAULT_SWAP_PROMPT = "Replace the person in the first image with the person from the second image. Keep the pose, framing, camera angle, clothing fit, lighting, background, and every other pixel of the scene identical. Match the face, hair, and body of the second image exactly. Photorealistic, no artifacts.";
|
|
2108
|
+
var DEFAULT_MOTION_PROMPT = "Natural, subtle motion that continues the scene: gentle camera movement, realistic body motion, consistent lighting. Keep the person identical to the still.";
|
|
2109
|
+
var SWAP_EDIT_NODE_ID = "edit";
|
|
2110
|
+
var SWAP_VIDEO_NODE_ID = "video";
|
|
2111
|
+
function defaultVideoModelFor(input) {
|
|
2112
|
+
if (input.videoModel) return input.videoModel;
|
|
2113
|
+
return input.endFrameUrl ? KEYFRAME_SWAP_VIDEO_MODEL : DEFAULT_SWAP_VIDEO_MODEL;
|
|
2114
|
+
}
|
|
2115
|
+
function buildCharacterSwapDefinition(input) {
|
|
2116
|
+
const videoModel = defaultVideoModelFor(input);
|
|
2117
|
+
return {
|
|
2118
|
+
version: { schema: 1 },
|
|
2119
|
+
id: "character-swap-still-to-video",
|
|
2120
|
+
label: "Character swap (still edit -> image-to-video)",
|
|
2121
|
+
params: [],
|
|
2122
|
+
nodes: [
|
|
2123
|
+
{
|
|
2124
|
+
id: SWAP_EDIT_NODE_ID,
|
|
2125
|
+
task: "image-edit",
|
|
2126
|
+
model: input.editModel ?? DEFAULT_SWAP_EDIT_MODEL,
|
|
2127
|
+
inputs: {
|
|
2128
|
+
prompt: { kind: "literal", value: input.swapPrompt ?? DEFAULT_SWAP_PROMPT },
|
|
2129
|
+
inputImages: { kind: "literal", value: [input.sceneFrameUrl, input.characterImageUrl] },
|
|
2130
|
+
aspectRatio: { kind: "literal", value: input.aspectRatio ?? "16:9" }
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
{
|
|
2134
|
+
id: SWAP_VIDEO_NODE_ID,
|
|
2135
|
+
task: "image-to-video",
|
|
2136
|
+
model: videoModel,
|
|
2137
|
+
inputs: {
|
|
2138
|
+
prompt: { kind: "literal", value: input.motionPrompt ?? DEFAULT_MOTION_PROMPT },
|
|
2139
|
+
inputImageUrl: { kind: "ref", node: SWAP_EDIT_NODE_ID, path: "url" },
|
|
2140
|
+
...input.endFrameUrl ? { endImageUrl: { kind: "literal", value: input.endFrameUrl } } : {}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
]
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
var CHARACTER_REFERENCE_PREFERENCE = ["full-body", "three-quarter", "face"];
|
|
2147
|
+
function pickCharacterReferenceUrl(references) {
|
|
2148
|
+
for (const category of CHARACTER_REFERENCE_PREFERENCE) {
|
|
2149
|
+
const hit = references.find((r) => r.category === category && typeof r.url === "string" && r.url.length > 0);
|
|
2150
|
+
if (hit) return { url: hit.url, category };
|
|
2151
|
+
}
|
|
2152
|
+
const any = references.find((r) => typeof r.url === "string" && r.url.length > 0);
|
|
2153
|
+
return any ? { url: any.url, category: any.category } : null;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/toolDef.ts
|
|
2157
|
+
function defineTool(def) {
|
|
2158
|
+
return def;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/workflowTools.ts
|
|
2162
|
+
import { randomUUID } from "crypto";
|
|
2163
|
+
import { z as z7 } from "zod";
|
|
2164
|
+
|
|
2165
|
+
// src/workflow.ts
|
|
2166
|
+
import { z as z6 } from "zod";
|
|
2167
|
+
var SUMMARY_TEXT_CHARS = 80;
|
|
2168
|
+
var SKEW_HINT = "update @marlinjai/studio-mcp to a version that matches the Studio";
|
|
2169
|
+
var ContractError = class extends Error {
|
|
2170
|
+
constructor(message) {
|
|
2171
|
+
super(message);
|
|
2172
|
+
this.name = "ContractError";
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
function firstIssue(error) {
|
|
2176
|
+
const issue = error.issues[0];
|
|
2177
|
+
if (!issue) return "invalid";
|
|
2178
|
+
const path = issue.path.map(String).join(".");
|
|
2179
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
2180
|
+
}
|
|
2181
|
+
function parseDocument(raw, what) {
|
|
2182
|
+
const r = WorkflowDocumentSchema.safeParse(raw);
|
|
2183
|
+
if (!r.success) {
|
|
2184
|
+
throw new ContractError(`${what} returned a workflow document this studio-mcp cannot read (${firstIssue(r.error)}); ${SKEW_HINT}`);
|
|
2185
|
+
}
|
|
2186
|
+
return r.data;
|
|
2187
|
+
}
|
|
2188
|
+
var OpsSchema = z6.array(OpSchema);
|
|
2189
|
+
function parseOps(raw, what) {
|
|
2190
|
+
const r = OpsSchema.safeParse(raw);
|
|
2191
|
+
if (!r.success) {
|
|
2192
|
+
throw new ContractError(`${what} returned ops this studio-mcp cannot read (${firstIssue(r.error)}); ${SKEW_HINT}`);
|
|
2193
|
+
}
|
|
2194
|
+
return r.data;
|
|
2195
|
+
}
|
|
2196
|
+
function projectionOf(doc) {
|
|
2197
|
+
const r = documentToDefinition(doc);
|
|
2198
|
+
return r.ok ? { ok: true, issues: [] } : { ok: false, issues: r.issues };
|
|
2199
|
+
}
|
|
2200
|
+
function clipText(text) {
|
|
2201
|
+
return text.length > SUMMARY_TEXT_CHARS ? text.slice(0, SUMMARY_TEXT_CHARS) : text;
|
|
2202
|
+
}
|
|
2203
|
+
function wireCounts(doc) {
|
|
2204
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2205
|
+
const of = (id) => {
|
|
2206
|
+
let c = counts.get(id);
|
|
2207
|
+
if (!c) {
|
|
2208
|
+
c = { in: 0, out: 0 };
|
|
2209
|
+
counts.set(id, c);
|
|
2210
|
+
}
|
|
2211
|
+
return c;
|
|
2212
|
+
};
|
|
2213
|
+
for (const e of doc.edges) {
|
|
2214
|
+
of(e.source).out += 1;
|
|
2215
|
+
of(e.target).in += 1;
|
|
2216
|
+
}
|
|
2217
|
+
return counts;
|
|
2218
|
+
}
|
|
2219
|
+
function summarizeNode(node, wires) {
|
|
2220
|
+
const base = { id: node.id, kind: node.kind, wires: { ...wires } };
|
|
2221
|
+
if (node.label !== void 0) base.label = node.label;
|
|
2222
|
+
switch (node.kind) {
|
|
2223
|
+
case "task":
|
|
2224
|
+
base.task = node.task;
|
|
2225
|
+
base.model = node.model;
|
|
2226
|
+
base.prompt = clipText(node.prompt);
|
|
2227
|
+
base.promptLength = node.prompt.length;
|
|
2228
|
+
if (node.activeGeneration !== void 0) base.activeGeneration = node.activeGeneration;
|
|
2229
|
+
break;
|
|
2230
|
+
case "prompt-source":
|
|
2231
|
+
base.prompt = clipText(node.text);
|
|
2232
|
+
base.promptLength = node.text.length;
|
|
2233
|
+
break;
|
|
2234
|
+
case "image-source":
|
|
2235
|
+
base.imageUrl = clipText(node.imageUrl);
|
|
2236
|
+
break;
|
|
2237
|
+
case "input":
|
|
2238
|
+
base.name = node.name;
|
|
2239
|
+
base.type = node.type;
|
|
2240
|
+
base.required = node.required;
|
|
2241
|
+
if (node.cardinality !== void 0) base.cardinality = node.cardinality;
|
|
2242
|
+
break;
|
|
2243
|
+
}
|
|
2244
|
+
return base;
|
|
2245
|
+
}
|
|
2246
|
+
function summarizeDocument(doc, meta) {
|
|
2247
|
+
const wires = wireCounts(doc);
|
|
2248
|
+
const summary = {
|
|
2249
|
+
savedId: meta.savedId,
|
|
2250
|
+
revision: meta.revision,
|
|
2251
|
+
label: meta.label ?? doc.label,
|
|
2252
|
+
nodes: doc.nodes.map((n) => summarizeNode(n, wires.get(n.id) ?? { in: 0, out: 0 })),
|
|
2253
|
+
edgeCount: doc.edges.length,
|
|
2254
|
+
groupCount: doc.groups.length,
|
|
2255
|
+
projection: meta.projection ?? projectionOf(doc)
|
|
2256
|
+
};
|
|
2257
|
+
if (meta.lossy !== void 0) summary.lossy = meta.lossy;
|
|
2258
|
+
if (meta.notes !== void 0) summary.notes = meta.notes;
|
|
2259
|
+
return summary;
|
|
2260
|
+
}
|
|
2261
|
+
function touchedNodeIds(doc, applied, inverse) {
|
|
2262
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2263
|
+
for (const op of applied) for (const id of opTouches(doc, op)) touched.add(id);
|
|
2264
|
+
for (const op of inverse) for (const id of opTouches(doc, op)) touched.add(id);
|
|
2265
|
+
const present = new Set(doc.nodes.map((n) => n.id));
|
|
2266
|
+
const ordered = doc.nodes.map((n) => n.id).filter((id) => touched.has(id));
|
|
2267
|
+
const removed = [...touched].filter((id) => !present.has(id)).sort();
|
|
2268
|
+
return [...ordered, ...removed];
|
|
2269
|
+
}
|
|
2270
|
+
function affectedNodes(doc, ids) {
|
|
2271
|
+
const wires = wireCounts(doc);
|
|
2272
|
+
const byId = new Map(doc.nodes.map((n) => [n.id, n]));
|
|
2273
|
+
return ids.map((id) => {
|
|
2274
|
+
const node = byId.get(id);
|
|
2275
|
+
return node ? summarizeNode(node, wires.get(id) ?? { in: 0, out: 0 }) : { id, removed: true };
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// src/workflowTools.ts
|
|
2280
|
+
var savedId = z7.string().min(1).describe('The saved workflow id (it carries a "saved:" prefix), from studio_workflow_create or the Studio workflow list.');
|
|
2281
|
+
var opsSchema = PatchSchema.shape.ops.describe(
|
|
2282
|
+
`The ops to apply in order, 1 to ${MAX_PATCH_OPS}: addNode, removeNode, setNodeProps, setNodeSetting, setBakedInput, connect, disconnect, exposeAsInput, freezeInput, setActiveGeneration, setLayout, group, ungroup, setGroupProps, setDocumentProps. Node ids are yours to choose (UUIDv7 recommended); a later op may reference an id added earlier in the same list.`
|
|
2283
|
+
);
|
|
2284
|
+
var paramsSchema = z7.record(z7.string(), z7.unknown()).describe("Run parameters by input-node name, checked against the declared inputs (unknown names, missing required values and wrong shapes are issues).");
|
|
2285
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2286
|
+
function compactTask(t, opts) {
|
|
2287
|
+
const { settings: rawSettings, models: rawModels, ...rest } = t;
|
|
2288
|
+
const settings = Array.isArray(rawSettings) ? rawSettings : [];
|
|
2289
|
+
const models = Array.isArray(rawModels) ? rawModels : [];
|
|
2290
|
+
return {
|
|
2291
|
+
...rest,
|
|
2292
|
+
settingCount: settings.length,
|
|
2293
|
+
modelCount: models.length,
|
|
2294
|
+
...opts.includeSettings ? { settings } : {},
|
|
2295
|
+
...opts.includeModels ? { models } : {}
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
function messageOf(body, ctx) {
|
|
2299
|
+
return typeof body.message === "string" ? ctx.client.redact(body.message) : null;
|
|
2300
|
+
}
|
|
2301
|
+
function applyErrorOf(raw, ctx) {
|
|
2302
|
+
const o = isRecord(raw) ? raw : {};
|
|
2303
|
+
return {
|
|
2304
|
+
opIndex: typeof o.opIndex === "number" ? o.opIndex : null,
|
|
2305
|
+
code: typeof o.code === "string" ? o.code : null,
|
|
2306
|
+
message: typeof o.message === "string" ? ctx.client.redact(o.message) : null
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
var REBASE_HINT = "Re-read the workflow (studio_workflow_get), rebase your ops on the current revision (node ids are stable) and apply again with that revision as baseRevision.";
|
|
2310
|
+
var RETRY_HINT = "The outcome of this attempt is unknown. Retry studio_workflow_apply with the SAME idempotencyKey and baseRevision: if the first attempt landed, the Studio returns its original result instead of applying the patch twice.";
|
|
2311
|
+
var NOT_EDITABLE_HINT = "This workflow is readable (studio_workflow_get) and runnable (studio_run_workflow with its savedId), but it cannot be patched. Build a new workflow with studio_workflow_create instead of trying to edit this one.";
|
|
2312
|
+
var LOSSY_HINT = "Read the notes: they name what the import drops for good. To go ahead, resend the SAME ops with acknowledgeLossy: true and a NEW idempotencyKey (the first patch materializes the imported document).";
|
|
2313
|
+
var IDEMPOTENCY_HINT = "This is not a replay: that key was used for a different patch. Send this patch again with a NEW idempotencyKey, on the revision above.";
|
|
2314
|
+
function stringList(raw, ctx) {
|
|
2315
|
+
if (!Array.isArray(raw)) return null;
|
|
2316
|
+
return raw.filter((n) => typeof n === "string").map((n) => ctx.client.redact(n));
|
|
2317
|
+
}
|
|
2318
|
+
function mapWriteFailure(err, ctx, info) {
|
|
2319
|
+
if (!(err instanceof StudioApiError)) return null;
|
|
2320
|
+
const body = isRecord(err.body) ? err.body : {};
|
|
2321
|
+
const code = typeof body.code === "string" ? body.code : null;
|
|
2322
|
+
if (err.status === 409 && code === "stale_revision" && info.savedId !== void 0) {
|
|
2323
|
+
const doc = parseDocument(body.document, "PATCH 409 stale_revision");
|
|
2324
|
+
const revision = typeof body.revision === "number" ? body.revision : null;
|
|
2325
|
+
if (revision === null) {
|
|
2326
|
+
throw new ContractError("PATCH 409 stale_revision carried no current revision; update @marlinjai/studio-mcp to a version that matches the Studio");
|
|
2327
|
+
}
|
|
2328
|
+
return {
|
|
2329
|
+
ok: false,
|
|
2330
|
+
code: "stale_revision",
|
|
2331
|
+
revision,
|
|
2332
|
+
summary: summarizeDocument(doc, { savedId: info.savedId, revision }),
|
|
2333
|
+
hint: REBASE_HINT
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
if (err.status === 400 && code === "invalid_patch") {
|
|
2337
|
+
return { ok: false, code: "invalid_patch", error: applyErrorOf(body.error, ctx) };
|
|
2338
|
+
}
|
|
2339
|
+
if (err.status === 403 && code === "forbidden") {
|
|
2340
|
+
return {
|
|
2341
|
+
ok: false,
|
|
2342
|
+
code: "forbidden",
|
|
2343
|
+
opIndex: typeof body.opIndex === "number" ? body.opIndex : null,
|
|
2344
|
+
missingScope: typeof body.missingScope === "string" ? body.missingScope : null,
|
|
2345
|
+
message: typeof body.message === "string" ? ctx.client.redact(body.message) : null
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
if (err.status === 422 && code === "not_editable") {
|
|
2349
|
+
return { ok: false, code: "not_editable", issues: Array.isArray(body.issues) ? body.issues : [], message: messageOf(body, ctx), hint: NOT_EDITABLE_HINT };
|
|
2350
|
+
}
|
|
2351
|
+
if (err.status === 422 && code === "lossy_import") {
|
|
2352
|
+
return { ok: false, code: "lossy_import", notes: stringList(body.notes, ctx) ?? [], message: messageOf(body, ctx), hint: LOSSY_HINT };
|
|
2353
|
+
}
|
|
2354
|
+
if (err.status === 422 && code === "idempotency_mismatch") {
|
|
2355
|
+
const revision = typeof body.revision === "number" ? body.revision : null;
|
|
2356
|
+
if (revision === null) {
|
|
2357
|
+
throw new ContractError("PATCH 422 idempotency_mismatch carried no current revision; update @marlinjai/studio-mcp to a version that matches the Studio");
|
|
2358
|
+
}
|
|
2359
|
+
return { ok: false, code: "idempotency_mismatch", revision, message: messageOf(body, ctx), hint: IDEMPOTENCY_HINT };
|
|
2360
|
+
}
|
|
2361
|
+
if (info.idempotencyKey !== void 0 && (err.status === 0 || err.status >= 500)) {
|
|
2362
|
+
return {
|
|
2363
|
+
ok: false,
|
|
2364
|
+
code: err.status === 0 ? "network_error" : "server_error",
|
|
2365
|
+
status: err.status,
|
|
2366
|
+
message: err.message,
|
|
2367
|
+
idempotencyKey: info.idempotencyKey,
|
|
2368
|
+
hint: RETRY_HINT
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
var studioWorkflowDescribe = defineTool({
|
|
2374
|
+
name: "studio_workflow_describe",
|
|
2375
|
+
description: "Describe the node vocabulary before authoring a workflow (read-only, free). Returns { tasks: [{ task, label, family, modality, jobKind, runnable, output, inputs: [{ port, wireable, cardinality, binding }], prompt: { needed, required, key } | null, settingCount, modelCount }], inputTypes (what an input node can declare), opTypes (what studio_workflow_apply accepts), limits: { maxPatchOps } }. Settings knobs and the model catalog are large, so they are dropped unless includeSettings / includeModels is true; filter with tasks to keep the result small.",
|
|
2376
|
+
inputSchema: {
|
|
2377
|
+
tasks: z7.array(z7.string().min(1)).min(1).optional().describe('Only these task ids (e.g. ["text-to-image", "image-to-video"]). An unknown id is an error that lists the known ones.'),
|
|
2378
|
+
includeSettings: z7.boolean().default(false).describe("Include each task's settings knobs (key, type, range, default)."),
|
|
2379
|
+
includeModels: z7.boolean().default(false).describe("Include the model catalog per task (id, label, provider, cost, isDefault).")
|
|
2380
|
+
},
|
|
2381
|
+
annotations: { readOnlyHint: true },
|
|
2382
|
+
handler: async (args, ctx) => {
|
|
2383
|
+
const res = await ctx.client.describeNodeTypes();
|
|
2384
|
+
let tasks = res.tasks;
|
|
2385
|
+
if (args.tasks) {
|
|
2386
|
+
const known = new Set(res.tasks.map((t) => t.task));
|
|
2387
|
+
const unknown = args.tasks.filter((t) => !known.has(t));
|
|
2388
|
+
if (unknown.length > 0) {
|
|
2389
|
+
throw new ToolInputError(`unknown task${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}; known tasks: ${[...known].join(", ")}`);
|
|
2390
|
+
}
|
|
2391
|
+
const wanted = new Set(args.tasks);
|
|
2392
|
+
tasks = res.tasks.filter((t) => wanted.has(t.task));
|
|
2393
|
+
}
|
|
2394
|
+
return {
|
|
2395
|
+
tasks: tasks.map((t) => compactTask(t, args)),
|
|
2396
|
+
inputTypes: res.inputTypes,
|
|
2397
|
+
opTypes: res.opTypes,
|
|
2398
|
+
limits: res.limits
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
var studioWorkflowGet = defineTool({
|
|
2403
|
+
name: "studio_workflow_get",
|
|
2404
|
+
description: "Read a saved workflow. By default returns a summary: { savedId, revision, label, readOnly, nodes: [{ id, kind, label?, task?, model?, prompt? (first 80 chars, plus promptLength), imageUrl?, name?, type?, required?, wires: { in, out } }], edgeCount, groupCount, projection: { ok, issues } } where revision is the baseRevision for studio_workflow_apply and projection says whether the workflow can run and why not. A workflow the canvas cannot represent comes back as { readOnly: true, definitionNodeCount, projection, hint } with no nodes: it is readable and runnable (studio_run_workflow with its savedId) but not patchable. needsLossyAcknowledgement: true means its first patch must carry acknowledgeLossy: true. full: true returns the whole response instead; use it only when you need every field.",
|
|
2405
|
+
inputSchema: {
|
|
2406
|
+
savedId,
|
|
2407
|
+
full: z7.boolean().default(false).describe("Return the whole response (the full document, or the stored definition of a read-only workflow) instead of the summary.")
|
|
2408
|
+
},
|
|
2409
|
+
annotations: { readOnlyHint: true },
|
|
2410
|
+
handler: async (args, ctx) => {
|
|
2411
|
+
const res = await ctx.client.getWorkflowDocument(args.savedId);
|
|
2412
|
+
if (args.full) return res;
|
|
2413
|
+
if (res.readOnly === true || res.document === null) {
|
|
2414
|
+
const definition = isRecord(res.definition) ? res.definition : null;
|
|
2415
|
+
const nodes = definition && Array.isArray(definition.nodes) ? definition.nodes : null;
|
|
2416
|
+
return {
|
|
2417
|
+
savedId: res.savedId,
|
|
2418
|
+
revision: res.revision,
|
|
2419
|
+
label: typeof res.label === "string" ? res.label : "",
|
|
2420
|
+
readOnly: true,
|
|
2421
|
+
projection: res.projection ?? { ok: false, issues: [] },
|
|
2422
|
+
...nodes ? { definitionNodeCount: nodes.length } : {},
|
|
2423
|
+
hint: NOT_EDITABLE_HINT
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
const doc = parseDocument(res.document, `GET workflow ${args.savedId}`);
|
|
2427
|
+
const notes = Array.isArray(res.notes) ? res.notes : void 0;
|
|
2428
|
+
const lossy = res.lossy === true;
|
|
2429
|
+
return {
|
|
2430
|
+
...summarizeDocument(doc, {
|
|
2431
|
+
savedId: res.savedId,
|
|
2432
|
+
revision: res.revision,
|
|
2433
|
+
...typeof res.label === "string" ? { label: res.label } : {},
|
|
2434
|
+
...res.projection ? { projection: res.projection } : {},
|
|
2435
|
+
...lossy ? { lossy: true } : {},
|
|
2436
|
+
...notes && notes.length > 0 ? { notes } : {}
|
|
2437
|
+
}),
|
|
2438
|
+
readOnly: false,
|
|
2439
|
+
// The server refuses the FIRST patch of a lossy import until the client
|
|
2440
|
+
// acknowledges the notes; say so here rather than after a failed patch.
|
|
2441
|
+
...lossy && res.materialized === false ? { needsLossyAcknowledgement: true } : {}
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
var studioWorkflowCreate = defineTool({
|
|
2446
|
+
name: "studio_workflow_create",
|
|
2447
|
+
description: 'Create a new saved workflow by applying ops to an empty document (call studio_workflow_describe first for the task vocabulary and ports). Returns { ok: true, savedId, revision, summary } (the studio_workflow_get summary shape, its projection tells you whether it can run). A bad patch returns { ok: false, code: "invalid_patch", error: { opIndex, code, message } } naming the offending op; a missing credential scope returns { ok: false, code: "forbidden", opIndex, missingScope }. Creating is free; running it with studio_run_workflow (savedId) is what spends money.',
|
|
2448
|
+
inputSchema: {
|
|
2449
|
+
ops: opsSchema,
|
|
2450
|
+
label: LabelSchema.optional().describe("Display label of the saved workflow."),
|
|
2451
|
+
projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects; omit for the default project.")
|
|
2452
|
+
},
|
|
2453
|
+
handler: async (args, ctx) => {
|
|
2454
|
+
let res;
|
|
2455
|
+
try {
|
|
2456
|
+
res = await ctx.client.createWorkflow({
|
|
2457
|
+
ops: args.ops,
|
|
2458
|
+
...args.label !== void 0 ? { label: args.label } : {},
|
|
2459
|
+
...args.projectId !== void 0 ? { projectId: args.projectId } : {}
|
|
2460
|
+
});
|
|
2461
|
+
} catch (err) {
|
|
2462
|
+
const mapped = mapWriteFailure(err, ctx, {});
|
|
2463
|
+
if (mapped) return mapped;
|
|
2464
|
+
throw err;
|
|
2465
|
+
}
|
|
2466
|
+
const doc = parseDocument(res.document, "POST workflow");
|
|
2467
|
+
return {
|
|
2468
|
+
ok: true,
|
|
2469
|
+
savedId: res.savedId,
|
|
2470
|
+
revision: res.revision,
|
|
2471
|
+
summary: summarizeDocument(doc, {
|
|
2472
|
+
savedId: res.savedId,
|
|
2473
|
+
revision: res.revision,
|
|
2474
|
+
...res.projection ? { projection: res.projection } : {}
|
|
2475
|
+
})
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
});
|
|
2479
|
+
var studioWorkflowApply = defineTool({
|
|
2480
|
+
name: "studio_workflow_apply",
|
|
2481
|
+
description: 'Change a saved workflow atomically: the ops are applied all-or-nothing on top of baseRevision (the revision from studio_workflow_get, studio_workflow_create or your last apply). Success returns { ok: true, savedId, revision, appliedCount, inverseCount, affectedNodes (summaries of the nodes the patch touched, removed ones as { id, removed: true }), projection: { ok, issues }, idempotencyKey, replayed }. Every refusal comes back as { ok: false, code, hint } you can act on rather than an error: "stale_revision" (re-read, rebase your ops, apply again on the revision returned), "invalid_patch" (error.opIndex names the offending op), "forbidden" (opIndex plus the missingScope your credential needs), "not_editable" (this workflow is readable and runnable but cannot be patched), "lossy_import" (read notes, then resend with acknowledgeLossy: true and a NEW idempotencyKey), "idempotency_mismatch" (that key belongs to a different patch: use a fresh one), and "network_error" / "server_error" which you retry with the SAME idempotencyKey so the patch can never apply twice.',
|
|
2482
|
+
inputSchema: {
|
|
2483
|
+
savedId,
|
|
2484
|
+
baseRevision: PatchSchema.shape.baseRevision.describe("The revision the ops were written against. The Studio refuses a stale one with stale_revision."),
|
|
2485
|
+
ops: opsSchema,
|
|
2486
|
+
idempotencyKey: PatchSchema.shape.idempotencyKey.describe(
|
|
2487
|
+
"Generated (a UUID) when omitted and returned in the result. Pass the same key only to retry the SAME patch after a network or server failure."
|
|
2488
|
+
),
|
|
2489
|
+
acknowledgeLossy: z7.boolean().optional().describe(
|
|
2490
|
+
'Only after a "lossy_import" refusal: confirms you read its notes and accept that materializing this pre-command-model workflow drops the bindings they name, for good. Send it with a NEW idempotencyKey.'
|
|
2491
|
+
)
|
|
2492
|
+
},
|
|
2493
|
+
handler: async (args, ctx) => {
|
|
2494
|
+
const idempotencyKey = args.idempotencyKey ?? randomUUID();
|
|
2495
|
+
let res;
|
|
2496
|
+
try {
|
|
2497
|
+
res = await ctx.client.applyWorkflowPatch(args.savedId, {
|
|
2498
|
+
baseRevision: args.baseRevision,
|
|
2499
|
+
ops: args.ops,
|
|
2500
|
+
idempotencyKey,
|
|
2501
|
+
...args.acknowledgeLossy !== void 0 ? { acknowledgeLossy: args.acknowledgeLossy } : {}
|
|
2502
|
+
});
|
|
2503
|
+
} catch (err) {
|
|
2504
|
+
const mapped = mapWriteFailure(err, ctx, { savedId: args.savedId, idempotencyKey });
|
|
2505
|
+
if (mapped) return mapped;
|
|
2506
|
+
throw err;
|
|
2507
|
+
}
|
|
2508
|
+
const doc = parseDocument(res.document, "PATCH workflow");
|
|
2509
|
+
const applied = parseOps(res.applied, "PATCH workflow (applied)");
|
|
2510
|
+
const inverse = parseOps(res.inverse, "PATCH workflow (inverse)");
|
|
2511
|
+
return {
|
|
2512
|
+
ok: true,
|
|
2513
|
+
savedId: args.savedId,
|
|
2514
|
+
revision: res.revision,
|
|
2515
|
+
appliedCount: applied.length,
|
|
2516
|
+
inverseCount: inverse.length,
|
|
2517
|
+
affectedNodes: affectedNodes(doc, touchedNodeIds(doc, applied, inverse)),
|
|
2518
|
+
projection: res.projection ?? projectionOf(doc),
|
|
2519
|
+
idempotencyKey,
|
|
2520
|
+
// True when this key replayed an earlier result: the patch was already
|
|
2521
|
+
// applied, nothing happened a second time (E3).
|
|
2522
|
+
replayed: res.replayed === true
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
});
|
|
2526
|
+
var studioWorkflowValidate = defineTool({
|
|
2527
|
+
name: "studio_workflow_validate",
|
|
2528
|
+
description: "Dry-run without side effects or cost: check a document (given inline, or fetched by savedId) with optional ops applied on top, and optional run params against the projected definition. Returns exactly what the Studio reports: { apply: { ok, error? } | null, document: { ok, code?, message? }, projection: { ok, issues, params? }, run: { ok, issues, optionalEmpty? } | null }. Use it before a large studio_workflow_apply and before studio_run_workflow, so missing required inputs, unknown params and unrunnable nodes surface before any money is spent.",
|
|
2529
|
+
inputSchema: {
|
|
2530
|
+
document: WorkflowDocumentSchema.optional().describe("The document to validate. Either this or savedId."),
|
|
2531
|
+
savedId: savedId.optional().describe("Fetch this saved workflow first and validate against its current document. Either this or document, not both."),
|
|
2532
|
+
ops: PatchSchema.shape.ops.optional().describe("Ops to apply to the document before projecting it (the same vocabulary and limits as studio_workflow_apply)."),
|
|
2533
|
+
params: paramsSchema.optional()
|
|
2534
|
+
},
|
|
2535
|
+
annotations: { readOnlyHint: true },
|
|
2536
|
+
handler: async (args, ctx) => {
|
|
2537
|
+
if (args.document && args.savedId) throw new ToolInputError("pass either `document` or `savedId`, not both");
|
|
2538
|
+
let document = args.document;
|
|
2539
|
+
if (!document) {
|
|
2540
|
+
if (!args.savedId) throw new ToolInputError("provide a `document` or a `savedId` to validate");
|
|
2541
|
+
const saved = await ctx.client.getWorkflowDocument(args.savedId);
|
|
2542
|
+
if (saved.readOnly === true || saved.document === null) {
|
|
2543
|
+
throw new ToolInputError(
|
|
2544
|
+
`workflow ${args.savedId} has no document to validate: the canvas cannot represent its stored definition (studio_workflow_get shows readOnly: true and why). It is still runnable with studio_run_workflow.`
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
document = parseDocument(saved.document, `GET workflow ${args.savedId}`);
|
|
2548
|
+
}
|
|
2549
|
+
return ctx.client.validateWorkflow({
|
|
2550
|
+
document,
|
|
2551
|
+
...args.ops ? { ops: args.ops } : {},
|
|
2552
|
+
...args.params ? { params: args.params } : {}
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
var studioWorkflowScreenshot = defineTool({
|
|
2557
|
+
name: "studio_workflow_screenshot",
|
|
2558
|
+
description: "Render a saved workflow's document as a diagram (labelled boxes and wires, positioned by the document's own layout) so you can see the graph you just built, the Figma get_screenshot equivalent. NOT a pixel-exact preview of the real canvas: a plain diagnostic diagram for checking topology (is everything wired the way I meant, did that addNode land where I expected). Returns { url, width, height }; the url is a signed Storage Brain link, valid for 24h. Free, read-only.",
|
|
2559
|
+
inputSchema: {
|
|
2560
|
+
savedId
|
|
2561
|
+
},
|
|
2562
|
+
annotations: { readOnlyHint: true },
|
|
2563
|
+
handler: async (args, ctx) => {
|
|
2564
|
+
return ctx.client.getWorkflowScreenshot(args.savedId);
|
|
2565
|
+
}
|
|
2566
|
+
});
|
|
2567
|
+
var WORKFLOW_TOOLS = [
|
|
2568
|
+
studioWorkflowDescribe,
|
|
2569
|
+
studioWorkflowGet,
|
|
2570
|
+
studioWorkflowCreate,
|
|
2571
|
+
studioWorkflowApply,
|
|
2572
|
+
studioWorkflowValidate,
|
|
2573
|
+
studioWorkflowScreenshot
|
|
2574
|
+
];
|
|
2575
|
+
|
|
2576
|
+
// src/tools.ts
|
|
2577
|
+
var slug = z8.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers and hyphens only").describe("URL-safe identifier: lowercase letters, numbers, hyphens.");
|
|
2578
|
+
var waitSeconds = z8.number().int().min(1).max(3600).default(DEFAULT_TIMEOUT_MS / 1e3).describe("Maximum seconds to wait before giving up and returning the id to poll later. Default 600.");
|
|
2579
|
+
var httpUrl = z8.string().url().refine((u) => /^https?:\/\//i.test(u), "must be an http(s) URL");
|
|
2580
|
+
function pollOpts(ctx, timeoutSec) {
|
|
2581
|
+
return { ...ctx.poll, timeoutMs: timeoutSec * 1e3 };
|
|
2582
|
+
}
|
|
2583
|
+
function projectJob(res) {
|
|
2584
|
+
return {
|
|
2585
|
+
jobId: res.job.id,
|
|
2586
|
+
status: res.job.status,
|
|
2587
|
+
model: res.job.model ?? null,
|
|
2588
|
+
costUsd: res.job.costUsd ?? null,
|
|
2589
|
+
errorMessage: res.job.errorMessage ?? null,
|
|
2590
|
+
url: res.resultAsset?.url ?? null,
|
|
2591
|
+
urls: (res.resultAssets ?? []).map((a) => a.url).filter((u) => typeof u === "string")
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
function projectNodeRun(nr) {
|
|
2595
|
+
return {
|
|
2596
|
+
nodeId: nr.nodeId,
|
|
2597
|
+
status: nr.status,
|
|
2598
|
+
url: nr.resultUrl ?? nr.results?.[0]?.url ?? null,
|
|
2599
|
+
kind: nr.results?.[0]?.kind ?? null,
|
|
2600
|
+
text: nr.results?.[0]?.text ?? null,
|
|
2601
|
+
costUsd: nr.costUsd ?? null,
|
|
2602
|
+
errorMessage: nr.errorMessage ?? null,
|
|
2603
|
+
// Only a parked judge node carries these: the pictures a person is being
|
|
2604
|
+
// asked to choose between, each with the `generationId` a decision names.
|
|
2605
|
+
...nr.candidates && nr.candidates.length > 0 ? { candidates: nr.candidates } : {}
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
function projectRun(res) {
|
|
2609
|
+
const parked = res.nodeRuns.filter((nr) => nr.status === "awaiting_decision");
|
|
2610
|
+
return {
|
|
2611
|
+
runId: res.run.id,
|
|
2612
|
+
status: res.run.status,
|
|
2613
|
+
savedId: res.run.savedId ?? null,
|
|
2614
|
+
costUsd: runCostUsd(res),
|
|
2615
|
+
nodes: res.nodeRuns.map(projectNodeRun),
|
|
2616
|
+
failure: runFailureSummary(res),
|
|
2617
|
+
...parked.length > 0 ? {
|
|
2618
|
+
waiting: {
|
|
2619
|
+
reason: "This run is parked on a human decision and will not move until a person decides. Do not keep polling, and do not decide on their behalf: show the candidates and ask. Submit their answer with studio_decide_judge.",
|
|
2620
|
+
nodeIds: parked.map((nr) => nr.nodeId)
|
|
2621
|
+
}
|
|
2622
|
+
} : {},
|
|
2623
|
+
// Run-level failure text. For a recipe run this is where a POST-RUN EFFECT
|
|
2624
|
+
// failure lands (the images generated, but creating the character or
|
|
2625
|
+
// attaching a reference did not): the run still reads `succeeded`, so
|
|
2626
|
+
// without this an agent would believe a character exists that does not.
|
|
2627
|
+
errorMessage: res.run.errorMessage ?? null
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
function nodeUrl(res, nodeId) {
|
|
2631
|
+
const nr = res.nodeRuns.find((n) => n.nodeId === nodeId);
|
|
2632
|
+
return nr?.resultUrl ?? nr?.results?.[0]?.url ?? null;
|
|
2633
|
+
}
|
|
2634
|
+
var studioListProjects = defineTool({
|
|
2635
|
+
name: "studio_list_projects",
|
|
2636
|
+
description: 'List the Studio projects visible to this API key (id, slug, name). A projectId is required by studio_run_workflow, studio_mint_character and studio_replace_character_in_video; call this first to find it (the usual one is slug "default").',
|
|
2637
|
+
inputSchema: {},
|
|
2638
|
+
annotations: { readOnlyHint: true },
|
|
2639
|
+
handler: async (_args, ctx) => {
|
|
2640
|
+
const { projects } = await ctx.client.listProjects();
|
|
2641
|
+
return { projects: projects.map((p) => ({ id: p.id, slug: p.slug, name: p.name })) };
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
var studioGenerateImage = defineTool({
|
|
2645
|
+
name: "studio_generate_image",
|
|
2646
|
+
description: "Generate one image with a brand's style applied (the Studio injects the brand's style prefix and reference images server-side). Optionally pin a minted character so its identity references are injected too. Submits a job and, when wait=true (default), polls until it finishes and returns the signed image URL and cost in USD. With wait=false it returns the jobId immediately for studio_get_job. Typical cost ~$0.04 per image.",
|
|
2647
|
+
inputSchema: {
|
|
2648
|
+
brandSlug: slug.describe('Brand to apply (e.g. "lola-stories"). Required by the API.'),
|
|
2649
|
+
brandMode: z8.string().optional().describe('Optional brand mode (e.g. "illustration"); modes are defined per brand.'),
|
|
2650
|
+
prompt: z8.string().min(1).describe("What to generate. The brand style prefix is prepended server-side."),
|
|
2651
|
+
characterSlug: slug.optional().describe("Optional minted character whose identity references are injected."),
|
|
2652
|
+
model: z8.string().optional().describe('Image model id (e.g. "kie/nano-banana-2", "fal/nano-banana-2"). Omit for the Studio default.'),
|
|
2653
|
+
aspectRatio: z8.string().optional().describe('Aspect ratio such as "1:1", "16:9", "3:4".'),
|
|
2654
|
+
imageSize: z8.string().optional().describe('Provider size hint, e.g. "1K", "2K".'),
|
|
2655
|
+
seed: z8.number().int().optional(),
|
|
2656
|
+
candidateCount: z8.number().int().min(1).max(4).optional().describe("Number of candidates (1 to 4)."),
|
|
2657
|
+
wait: z8.boolean().default(true).describe("Poll until the job finishes (default true)."),
|
|
2658
|
+
waitSeconds
|
|
2659
|
+
},
|
|
2660
|
+
handler: async (args, ctx) => {
|
|
2661
|
+
const settings = {};
|
|
2662
|
+
if (args.model) settings.model = args.model;
|
|
2663
|
+
if (args.aspectRatio) settings.aspectRatio = args.aspectRatio;
|
|
2664
|
+
if (args.imageSize) settings.imageSize = args.imageSize;
|
|
2665
|
+
if (args.seed !== void 0) settings.seed = args.seed;
|
|
2666
|
+
if (args.candidateCount !== void 0) settings.candidateCount = args.candidateCount;
|
|
2667
|
+
const submitted = await ctx.client.generate({
|
|
2668
|
+
brandSlug: args.brandSlug,
|
|
2669
|
+
...args.brandMode ? { brandMode: args.brandMode } : {},
|
|
2670
|
+
prompt: args.prompt,
|
|
2671
|
+
...args.characterSlug ? { characterSlug: args.characterSlug } : {},
|
|
2672
|
+
...Object.keys(settings).length > 0 ? { settings } : {}
|
|
2673
|
+
});
|
|
2674
|
+
if (!args.wait) return { jobId: submitted.jobId, kind: submitted.kind, status: "queued" };
|
|
2675
|
+
const done = await waitForJob(ctx.client, submitted.jobId, pollOpts(ctx, args.waitSeconds));
|
|
2676
|
+
const out = projectJob(done);
|
|
2677
|
+
if (done.job.status !== "succeeded") {
|
|
2678
|
+
throw new Error(`Job ${submitted.jobId} ${done.job.status}: ${done.job.errorMessage ?? "no error message from the API"}`);
|
|
2679
|
+
}
|
|
2680
|
+
return { ...out, kind: submitted.kind };
|
|
2681
|
+
}
|
|
2682
|
+
});
|
|
2683
|
+
var studioGetJob = defineTool({
|
|
2684
|
+
name: "studio_get_job",
|
|
2685
|
+
description: "Fetch the status of a generation job by jobId: status (queued, running, succeeded, failed, canceled), cost, model, error message and a freshly signed result URL once it succeeded. Set wait=true to block until the job reaches a terminal state.",
|
|
2686
|
+
inputSchema: {
|
|
2687
|
+
jobId: z8.string().min(1),
|
|
2688
|
+
wait: z8.boolean().default(false).describe("Poll until terminal (default false: return the current state)."),
|
|
2689
|
+
waitSeconds
|
|
2690
|
+
},
|
|
2691
|
+
annotations: { readOnlyHint: true },
|
|
2692
|
+
handler: async (args, ctx) => {
|
|
2693
|
+
const res = args.wait ? await waitForJob(ctx.client, args.jobId, pollOpts(ctx, args.waitSeconds)) : await ctx.client.getJob(args.jobId);
|
|
2694
|
+
return projectJob(res);
|
|
2695
|
+
}
|
|
2696
|
+
});
|
|
2697
|
+
var studioListCharacters = defineTool({
|
|
2698
|
+
name: "studio_list_characters",
|
|
2699
|
+
description: "List all minted characters (slug, name, descriptor prompt, default model). Use a slug with studio_get_character for its reference images, or with studio_generate_image to render that character.",
|
|
2700
|
+
inputSchema: {},
|
|
2701
|
+
annotations: { readOnlyHint: true },
|
|
2702
|
+
handler: async (_args, ctx) => {
|
|
2703
|
+
const { characters } = await ctx.client.listCharacters();
|
|
2704
|
+
return { characters };
|
|
2705
|
+
}
|
|
2706
|
+
});
|
|
2707
|
+
var studioGetCharacter = defineTool({
|
|
2708
|
+
name: "studio_get_character",
|
|
2709
|
+
description: "Fetch one character by slug with its full context: descriptor and negative prompts plus every frozen reference image (category such as face, three-quarter, profile, full-body, close-up, lighting-warm, lighting-cool; freshly signed URL). The full-body or face reference URL is what studio_replace_character_in_video needs as characterImageUrl.",
|
|
2710
|
+
inputSchema: { slug },
|
|
2711
|
+
annotations: { readOnlyHint: true },
|
|
2712
|
+
handler: async (args, ctx) => {
|
|
2713
|
+
const { character } = await ctx.client.getCharacter(args.slug);
|
|
2714
|
+
return { character };
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
var studioCreateCharacter = defineTool({
|
|
2718
|
+
name: "studio_create_character",
|
|
2719
|
+
description: "Create an empty character record (no reference images yet). Prefer studio_mint_character, which generates the 7-view identity sheet and freezes it as references in one call. Use this only when you will upload your own reference images. Fails with 409 if the slug exists.",
|
|
2720
|
+
inputSchema: {
|
|
2721
|
+
name: z8.string().min(1).describe('Display name, e.g. "Bruno Hartmann".'),
|
|
2722
|
+
slug,
|
|
2723
|
+
descriptorPrompt: z8.string().min(1).describe("The identity description prepended to every generation that uses this character."),
|
|
2724
|
+
negativePrompt: z8.string().optional(),
|
|
2725
|
+
defaultModel: z8.string().optional().describe("Default image model id for this character.")
|
|
2726
|
+
},
|
|
2727
|
+
handler: async (args, ctx) => {
|
|
2728
|
+
const { character } = await ctx.client.createCharacter({
|
|
2729
|
+
name: args.name,
|
|
2730
|
+
slug: args.slug,
|
|
2731
|
+
descriptorPrompt: args.descriptorPrompt,
|
|
2732
|
+
...args.negativePrompt !== void 0 ? { negativePrompt: args.negativePrompt } : {},
|
|
2733
|
+
...args.defaultModel !== void 0 ? { defaultModel: args.defaultModel } : {}
|
|
2734
|
+
});
|
|
2735
|
+
return { character };
|
|
2736
|
+
}
|
|
2737
|
+
});
|
|
2738
|
+
var studioMintCharacter = defineTool({
|
|
2739
|
+
name: "studio_mint_character",
|
|
2740
|
+
description: "Mint a consistent character from a text description: runs the curated character-sheet workflow (seed face with fal/nano-banana-2, then six identity-pinned edits with fal/nano-banana-2-edit: three-quarter, profile, full-body, close-up, warm lighting, cool lighting), waits for it, creates the character, and uploads the 7 views as frozen references with the right categories. Returns the slug, reference count, per-view URLs, the runId and total cost (about $0.30). The description should cover face, hair, age, build and distinguishing features; wardrobe is standardized to a plain gray tee. Takes 2 to 5 minutes.",
|
|
2741
|
+
inputSchema: {
|
|
2742
|
+
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
2743
|
+
name: z8.string().min(1).describe("Display name."),
|
|
2744
|
+
slug,
|
|
2745
|
+
description: z8.string().min(10).describe("Seed identity description (face, hair, age, build, distinguishing features)."),
|
|
2746
|
+
descriptorPrompt: z8.string().optional().describe("Override for the character's stored descriptor prompt. Defaults to the description."),
|
|
2747
|
+
negativePrompt: z8.string().optional(),
|
|
2748
|
+
defaultModel: z8.string().optional(),
|
|
2749
|
+
seedModel: z8.string().optional().describe("Override the seed text-to-image model (default fal/nano-banana-2)."),
|
|
2750
|
+
viewModel: z8.string().optional().describe("Override the view image-edit model (default fal/nano-banana-2-edit)."),
|
|
2751
|
+
visibility: z8.enum(["listed", "unlisted"]).optional().describe("Whether the run shows in the Studio workflow list."),
|
|
2752
|
+
waitSeconds
|
|
2753
|
+
},
|
|
2754
|
+
handler: async (args, ctx) => {
|
|
2755
|
+
const definition = characterSheetDefinition({
|
|
2756
|
+
...args.seedModel ? { seedModel: args.seedModel } : {},
|
|
2757
|
+
...args.viewModel ? { viewModel: args.viewModel } : {}
|
|
2758
|
+
});
|
|
2759
|
+
const params = buildCharacterSheetParams(args.description);
|
|
2760
|
+
const started = await ctx.client.runWorkflow({
|
|
2761
|
+
projectId: args.projectId,
|
|
2762
|
+
definition,
|
|
2763
|
+
params,
|
|
2764
|
+
...args.visibility ? { visibility: args.visibility } : {}
|
|
2765
|
+
});
|
|
2766
|
+
const run = await waitForRun(ctx.client, started.runId, pollOpts(ctx, args.waitSeconds));
|
|
2767
|
+
const views = CHARACTER_SHEET_NODES.map((n) => ({ ...n, url: nodeUrl(run, n.nodeId) }));
|
|
2768
|
+
const missing = views.filter((v) => !v.url);
|
|
2769
|
+
if (missing.length > 0) {
|
|
2770
|
+
const failure = runFailureSummary(run);
|
|
2771
|
+
throw new Error(
|
|
2772
|
+
`Character sheet run ${started.runId} ended ${run.run.status} with ${missing.length} of ${views.length} views missing (${missing.map((v) => v.nodeId).join(", ")}).${failure ? ` Failures: ${failure}.` : ""} No character was created; cost so far $${runCostUsd(run).toFixed(2)}.`
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
const { character } = await ctx.client.createCharacter({
|
|
2776
|
+
name: args.name,
|
|
2777
|
+
slug: args.slug,
|
|
2778
|
+
descriptorPrompt: args.descriptorPrompt ?? args.description,
|
|
2779
|
+
...args.negativePrompt !== void 0 ? { negativePrompt: args.negativePrompt } : {},
|
|
2780
|
+
...args.defaultModel !== void 0 ? { defaultModel: args.defaultModel } : {}
|
|
2781
|
+
});
|
|
2782
|
+
const references = [];
|
|
2783
|
+
for (const view of views) {
|
|
2784
|
+
const file = await ctx.client.downloadUrl(view.url);
|
|
2785
|
+
const category = MINT_REFERENCE_CATEGORIES[view.nodeId];
|
|
2786
|
+
const { reference } = await ctx.client.uploadCharacterReference(args.slug, {
|
|
2787
|
+
file,
|
|
2788
|
+
category,
|
|
2789
|
+
label: view.label
|
|
2790
|
+
});
|
|
2791
|
+
references.push({ nodeId: view.nodeId, category, referenceId: reference.id, url: reference.url });
|
|
2792
|
+
}
|
|
2793
|
+
return {
|
|
2794
|
+
slug: character.slug,
|
|
2795
|
+
name: character.name,
|
|
2796
|
+
runId: started.runId,
|
|
2797
|
+
referenceCount: references.length,
|
|
2798
|
+
references,
|
|
2799
|
+
costUsd: runCostUsd(run)
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
var studioCreateBrand = defineTool({
|
|
2804
|
+
name: "studio_create_brand",
|
|
2805
|
+
description: "Create a brand (a style preset: name, slug, optional style prefix prepended to every prompt, optional free-text context). Upload reference images afterwards with studio_upload_brand_reference. Fails with 409 if the slug exists.",
|
|
2806
|
+
inputSchema: {
|
|
2807
|
+
name: z8.string().min(1),
|
|
2808
|
+
slug,
|
|
2809
|
+
stylePrefix: z8.string().optional().describe("Text prepended to every prompt generated under this brand."),
|
|
2810
|
+
context: z8.string().optional().describe("Markdown brand context (tone, palette, do/don't)."),
|
|
2811
|
+
defaultModel: z8.string().optional(),
|
|
2812
|
+
maxReferenceImages: z8.number().int().min(0).max(8).optional()
|
|
2813
|
+
},
|
|
2814
|
+
handler: async (args, ctx) => {
|
|
2815
|
+
const { config } = await ctx.client.createBrand({
|
|
2816
|
+
name: args.name,
|
|
2817
|
+
slug: args.slug,
|
|
2818
|
+
...args.stylePrefix !== void 0 ? { stylePrefix: args.stylePrefix } : {},
|
|
2819
|
+
...args.context !== void 0 ? { context: args.context } : {},
|
|
2820
|
+
...args.defaultModel !== void 0 ? { defaultModel: args.defaultModel } : {},
|
|
2821
|
+
...args.maxReferenceImages !== void 0 ? { maxReferenceImages: args.maxReferenceImages } : {}
|
|
2822
|
+
});
|
|
2823
|
+
return { brand: config };
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
var studioUploadBrandReference = defineTool({
|
|
2827
|
+
name: "studio_upload_brand_reference",
|
|
2828
|
+
description: `Upload one reference image to a brand's library from a local file path or an http(s) URL. The Studio converts it to a 512px WebP and injects it into generations under that brand (up to the brand's maxReferenceImages). Category groups references (default "general"); tags and a 1 to 10 quality score are optional.`,
|
|
2829
|
+
inputSchema: {
|
|
2830
|
+
brandSlug: slug,
|
|
2831
|
+
source: z8.string().min(1).describe("Local file path or http(s) URL of an image."),
|
|
2832
|
+
category: z8.string().optional().describe('Library category, default "general".'),
|
|
2833
|
+
label: z8.string().optional().describe("Optional file name / label."),
|
|
2834
|
+
tags: z8.array(z8.string()).optional(),
|
|
2835
|
+
score: z8.number().int().min(1).max(10).optional()
|
|
2836
|
+
},
|
|
2837
|
+
handler: async (args, ctx) => {
|
|
2838
|
+
const file = await loadImageSource(args.source, ctx.client);
|
|
2839
|
+
const { reference } = await ctx.client.uploadBrandReference(args.brandSlug, {
|
|
2840
|
+
file,
|
|
2841
|
+
...args.category ? { category: args.category } : {},
|
|
2842
|
+
...args.label ? { label: args.label } : {},
|
|
2843
|
+
...args.tags ? { tags: args.tags } : {},
|
|
2844
|
+
...args.score !== void 0 ? { score: args.score } : {}
|
|
2845
|
+
});
|
|
2846
|
+
return { reference };
|
|
2847
|
+
}
|
|
2848
|
+
});
|
|
2849
|
+
var workflowDefinitionSchema = WorkflowDefinitionSchema.describe(
|
|
2850
|
+
'Inline workflow DAG: { id, label?, params?: [{ name, type: string|number|boolean|json, required?, label?, description?, inputType?, cardinality?: one|many, default? }], nodes: [{ id, task (one of the model tasks listed), model (catalog id, e.g. fal/nano-banana-2-edit), inputs, retry? }] }. Node inputs are bindings: {kind:"literal",value}, {kind:"param",name,wrap?:"array"} or {kind:"ref",node,path:"url",wrap?:"array"}. Image-edit nodes take inputImages (array, use wrap:"array" on a ref or param); image-to-video nodes take inputImageUrl (string).'
|
|
2851
|
+
);
|
|
2852
|
+
var studioRunWorkflow = defineTool({
|
|
2853
|
+
name: "studio_run_workflow",
|
|
2854
|
+
description: `Run a generation workflow DAG. Pass an inline definition (nodes of provider calls wired by ref bindings; this is what the hosted Studio accepts) or the savedId of a workflow saved in the Studio (the Studio runs that workflow's current document, exactly what the canvas would run). Params fill {kind:"param"} bindings. Returns the runId plus per-node status, URLs and cost; with wait=true (default) it blocks until the run is terminal (succeeded, failed, partial). Each node becomes a paid job.`,
|
|
2855
|
+
inputSchema: {
|
|
2856
|
+
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
2857
|
+
definition: workflowDefinitionSchema.optional(),
|
|
2858
|
+
savedId: z8.string().optional().describe("Id of a saved workflow (from studio_workflow_create, studio_workflow_get or a listed run) to run instead of an inline definition. An unknown id is a 404 naming it; a workflow that is not runnable yet comes back as not_runnable with per-node issues."),
|
|
2859
|
+
params: z8.record(z8.string(), z8.unknown()).optional().describe("Values for the definition's params."),
|
|
2860
|
+
nodeIds: z8.array(z8.string()).optional().describe("Run only this subset of nodes (requires an inline `definition` or a `savedId`)."),
|
|
2861
|
+
runs: z8.number().int().min(1).max(8).optional().describe("Repeat a node subset N times (1 to 8)."),
|
|
2862
|
+
visibility: z8.enum(["listed", "unlisted"]).optional().describe("Whether the run shows in the Studio workflow list."),
|
|
2863
|
+
budgetUsd: z8.number().nonnegative().optional().describe(
|
|
2864
|
+
'S6b: hard cap on the estimated cost, verified server-side before anything is created. An unverifiable estimate (a credits-only or unpriced executing node) refuses too, never proceeds silently. Refusal: { code: "budget_exceeded", estimate }. For `runs`>1 this is the AGGREGATE cap across every iteration.'
|
|
2865
|
+
),
|
|
2866
|
+
confirm: z8.boolean().optional().describe(
|
|
2867
|
+
'S6b: bypasses the confirmation threshold for this one request (a flat $10 today) when the estimate meets or exceeds it. Without this, such a run refuses with { code: "confirmation_required", estimate }.'
|
|
2868
|
+
),
|
|
2869
|
+
wait: z8.boolean().default(true),
|
|
2870
|
+
waitSeconds
|
|
2871
|
+
},
|
|
2872
|
+
handler: async (args, ctx) => {
|
|
2873
|
+
if (!args.definition && !args.savedId) {
|
|
2874
|
+
throw new ToolInputError("provide an inline `definition` or a `savedId`");
|
|
2875
|
+
}
|
|
2876
|
+
if (args.nodeIds && args.nodeIds.length > 0 && !args.definition && !args.savedId) {
|
|
2877
|
+
throw new ToolInputError("running a node subset requires an inline `definition` or a `savedId`");
|
|
2878
|
+
}
|
|
2879
|
+
const started = await ctx.client.runWorkflow({
|
|
2880
|
+
projectId: args.projectId,
|
|
2881
|
+
...args.definition ? { definition: args.definition } : {},
|
|
2882
|
+
...args.params ? { params: args.params } : {},
|
|
2883
|
+
...args.nodeIds && args.nodeIds.length > 0 ? { nodeIds: args.nodeIds } : {},
|
|
2884
|
+
...args.runs !== void 0 ? { runs: args.runs } : {},
|
|
2885
|
+
...args.savedId ? { savedId: args.savedId } : {},
|
|
2886
|
+
...args.visibility ? { visibility: args.visibility } : {},
|
|
2887
|
+
...args.budgetUsd !== void 0 ? { budgetUsd: args.budgetUsd } : {},
|
|
2888
|
+
...args.confirm !== void 0 ? { confirm: args.confirm } : {}
|
|
2889
|
+
});
|
|
2890
|
+
if (!args.wait) return { runId: started.runId, runIds: started.runIds, status: started.status };
|
|
2891
|
+
const run = await waitForRun(ctx.client, started.runId, pollOpts(ctx, args.waitSeconds));
|
|
2892
|
+
return { ...projectRun(run), runIds: started.runIds };
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
var studioGetRun = defineTool({
|
|
2896
|
+
name: "studio_get_run",
|
|
2897
|
+
description: "Fetch a workflow run by runId: overall status (pending, running, succeeded, failed, partial, awaiting_decision), total cost, and per-node status, result URL, cost and error message. Set wait=true to block until the run settles. A run at awaiting_decision is parked on a person at a judge node: waiting returns immediately with a `waiting` block naming the node and its candidates, because polling can never clear it; ask the user to choose and submit their answer with studio_decide_judge.",
|
|
2898
|
+
inputSchema: {
|
|
2899
|
+
runId: z8.string().min(1),
|
|
2900
|
+
wait: z8.boolean().default(false),
|
|
2901
|
+
waitSeconds
|
|
2902
|
+
},
|
|
2903
|
+
annotations: { readOnlyHint: true },
|
|
2904
|
+
handler: async (args, ctx) => {
|
|
2905
|
+
const res = args.wait ? await waitForRun(ctx.client, args.runId, pollOpts(ctx, args.waitSeconds)) : await ctx.client.getRun(args.runId);
|
|
2906
|
+
return projectRun(res);
|
|
2907
|
+
}
|
|
2908
|
+
});
|
|
2909
|
+
var studioDecideJudge = defineTool({
|
|
2910
|
+
name: "studio_decide_judge",
|
|
2911
|
+
description: "Submit a PERSON'S decision on a parked judge node (a run whose status is awaiting_decision). Approving that candidate resumes the run from that node; rejecting fails the node and stops the branch. Never call this on your own judgement: a judge node exists precisely because a human is meant to look, so show them the candidates from studio_get_run's `waiting` block and pass on the answer they give. `candidate.generationId` comes from that same block, and may be omitted only when the node has exactly one candidate.",
|
|
2912
|
+
inputSchema: {
|
|
2913
|
+
runId: z8.string().min(1),
|
|
2914
|
+
nodeId: z8.string().min(1),
|
|
2915
|
+
verdict: z8.enum(["approved", "rejected"]),
|
|
2916
|
+
candidate: z8.object({ generationId: z8.string().min(1), index: z8.number().int().min(0).optional() }).optional(),
|
|
2917
|
+
reason: z8.string().max(500).optional()
|
|
2918
|
+
},
|
|
2919
|
+
annotations: { destructiveHint: false, idempotentHint: false },
|
|
2920
|
+
handler: async (args, ctx) => {
|
|
2921
|
+
await ctx.client.decideJudgeNode(args.runId, args.nodeId, {
|
|
2922
|
+
verdict: args.verdict,
|
|
2923
|
+
...args.candidate ? { candidate: args.candidate } : {},
|
|
2924
|
+
...args.reason ? { reason: args.reason } : {}
|
|
2925
|
+
});
|
|
2926
|
+
return projectRun(await ctx.client.getRun(args.runId));
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
var studioGetRunOutput = defineTool({
|
|
2930
|
+
name: "studio_get_run_output",
|
|
2931
|
+
description: "Get a FRESH signed URL for one node's output of a workflow run (signed URLs expire; call this when an earlier URL stopped working). Returns url, kind (image, video, model3d) and mime.",
|
|
2932
|
+
inputSchema: {
|
|
2933
|
+
runId: z8.string().min(1),
|
|
2934
|
+
nodeId: z8.string().min(1)
|
|
2935
|
+
},
|
|
2936
|
+
annotations: { readOnlyHint: true },
|
|
2937
|
+
handler: async (args, ctx) => {
|
|
2938
|
+
const res = await ctx.client.getRunNodeUrl(args.runId, args.nodeId);
|
|
2939
|
+
return { runId: args.runId, nodeId: args.nodeId, url: res.url, kind: res.kind ?? null, mime: res.mime ?? null };
|
|
2940
|
+
}
|
|
2941
|
+
});
|
|
2942
|
+
var studioReplaceCharacterInVideo = defineTool({
|
|
2943
|
+
name: "studio_replace_character_in_video",
|
|
2944
|
+
description: "Put a minted character into a video shot using the proven still-swap recipe: (1) an image-edit node (fal/nano-banana-2-edit) replaces the person in a scene FRAME with the character, keeping everything else identical (16:9); (2) an image-to-video node animates that still (default fal/seedance-pro-image-to-video; with endFrameUrl it switches to fal/kling-v2-5-image-to-video and binds the end keyframe so the clip lands on it). Give the character as characterSlug (its full-body reference is used, falling back to three-quarter then face) or as an explicit characterReferenceUrl. Requires a frame URL: extract a representative frame from the video yourself first (this tool does not decode video). Waits for both nodes and returns stillUrl, videoUrl, runId and costUsd (about $0.70 per take). Keep generated cuts under 5 seconds and budget 2 to 3 takes per shot. Do not use video-to-video swap models on dark or handheld footage; this recipe replaces that.",
|
|
2945
|
+
inputSchema: {
|
|
2946
|
+
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
2947
|
+
sceneFrameUrl: httpUrl.describe("Public or signed URL of ONE frame of the target shot (png/jpg)."),
|
|
2948
|
+
characterSlug: slug.optional().describe("Minted character to insert; its full-body reference (fallback three-quarter, face) is resolved automatically."),
|
|
2949
|
+
characterReferenceUrl: httpUrl.optional().describe("Explicit character reference image URL (use instead of characterSlug)."),
|
|
2950
|
+
endFrameUrl: httpUrl.optional().describe("Optional second approved keyframe for the END of the clip. Switches the video model to Kling (start + end frame) unless videoModel is set explicitly."),
|
|
2951
|
+
swapPrompt: z8.string().optional().describe("Override the still-swap edit prompt."),
|
|
2952
|
+
motionPrompt: z8.string().optional().describe("Override the motion description for the video node."),
|
|
2953
|
+
videoModel: z8.string().optional().describe(`Image-to-video model id. Default ${DEFAULT_SWAP_VIDEO_MODEL}, or ${KEYFRAME_SWAP_VIDEO_MODEL} when endFrameUrl is given.`),
|
|
2954
|
+
editModel: z8.string().default(DEFAULT_SWAP_EDIT_MODEL).describe("Image-edit model id for the still swap."),
|
|
2955
|
+
aspectRatio: z8.string().default("16:9"),
|
|
2956
|
+
visibility: z8.enum(["listed", "unlisted"]).optional(),
|
|
2957
|
+
waitSeconds
|
|
2958
|
+
},
|
|
2959
|
+
handler: async (args, ctx) => {
|
|
2960
|
+
if (!args.characterSlug && !args.characterReferenceUrl) {
|
|
2961
|
+
throw new ToolInputError("provide characterSlug or characterReferenceUrl");
|
|
2962
|
+
}
|
|
2963
|
+
let characterImageUrl = args.characterReferenceUrl;
|
|
2964
|
+
let characterReferenceCategory = null;
|
|
2965
|
+
if (!characterImageUrl) {
|
|
2966
|
+
const { character } = await ctx.client.getCharacter(args.characterSlug);
|
|
2967
|
+
const picked = pickCharacterReferenceUrl(character.references ?? []);
|
|
2968
|
+
if (!picked) {
|
|
2969
|
+
throw new ToolInputError(
|
|
2970
|
+
`character ${args.characterSlug} has no reference images with a URL; mint it first (studio_mint_character) or pass characterReferenceUrl`
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
characterImageUrl = picked.url;
|
|
2974
|
+
characterReferenceCategory = picked.category;
|
|
2975
|
+
}
|
|
2976
|
+
const videoModel = defaultVideoModelFor({ endFrameUrl: args.endFrameUrl, videoModel: args.videoModel });
|
|
2977
|
+
const definition = buildCharacterSwapDefinition({
|
|
2978
|
+
sceneFrameUrl: args.sceneFrameUrl,
|
|
2979
|
+
characterImageUrl,
|
|
2980
|
+
...args.endFrameUrl ? { endFrameUrl: args.endFrameUrl } : {},
|
|
2981
|
+
...args.swapPrompt ? { swapPrompt: args.swapPrompt } : {},
|
|
2982
|
+
...args.motionPrompt ? { motionPrompt: args.motionPrompt } : {},
|
|
2983
|
+
editModel: args.editModel,
|
|
2984
|
+
videoModel,
|
|
2985
|
+
aspectRatio: args.aspectRatio
|
|
2986
|
+
});
|
|
2987
|
+
const started = await ctx.client.runWorkflow({
|
|
2988
|
+
projectId: args.projectId,
|
|
2989
|
+
definition,
|
|
2990
|
+
...args.visibility ? { visibility: args.visibility } : {}
|
|
2991
|
+
});
|
|
2992
|
+
const run = await waitForRun(ctx.client, started.runId, pollOpts(ctx, args.waitSeconds));
|
|
2993
|
+
const stillUrl = nodeUrl(run, SWAP_EDIT_NODE_ID);
|
|
2994
|
+
const videoUrl = nodeUrl(run, SWAP_VIDEO_NODE_ID);
|
|
2995
|
+
if (!stillUrl || !videoUrl) {
|
|
2996
|
+
const failure = runFailureSummary(run);
|
|
2997
|
+
throw new Error(
|
|
2998
|
+
`Character swap run ${started.runId} ended ${run.run.status}: ${!stillUrl ? "no swapped still" : "still ok"}, ${!videoUrl ? "no video" : "video ok"}.${failure ? ` Failures: ${failure}.` : ""} Cost so far $${runCostUsd(run).toFixed(2)}.`
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
3001
|
+
return {
|
|
3002
|
+
runId: started.runId,
|
|
3003
|
+
status: run.run.status,
|
|
3004
|
+
stillUrl,
|
|
3005
|
+
videoUrl,
|
|
3006
|
+
costUsd: runCostUsd(run),
|
|
3007
|
+
videoModel,
|
|
3008
|
+
characterReferenceUrl: characterImageUrl,
|
|
3009
|
+
characterReferenceCategory,
|
|
3010
|
+
stillNodeId: SWAP_EDIT_NODE_ID,
|
|
3011
|
+
videoNodeId: SWAP_VIDEO_NODE_ID
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
});
|
|
3015
|
+
var studioGetSpend = defineTool({
|
|
3016
|
+
name: "studio_get_spend",
|
|
3017
|
+
description: `Read the spend ledger: total USD and job counts for today, the last 7 days and the last 30 days, plus the most recent paid actions. tzOffsetMinutes is the viewer's Date.getTimezoneOffset() so "today" rolls at local midnight (default UTC).`,
|
|
3018
|
+
inputSchema: {
|
|
3019
|
+
tzOffsetMinutes: z8.number().int().min(-840).max(840).optional()
|
|
3020
|
+
},
|
|
3021
|
+
annotations: { readOnlyHint: true },
|
|
3022
|
+
handler: async (args, ctx) => {
|
|
3023
|
+
const ledger = await ctx.client.getSpend(args.tzOffsetMinutes);
|
|
3024
|
+
return ledger;
|
|
3025
|
+
}
|
|
3026
|
+
});
|
|
3027
|
+
var studioGetUsage = defineTool({
|
|
3028
|
+
name: "studio_get_usage",
|
|
3029
|
+
description: "Read this key's tenant against its plan (docs/plans/2026-08-21-studio-api-keys-and-quotas.md): month-to-date USD spend and job count, the plan's monthly cap and requests-per-minute limit (null = unlimited), and a per-model breakdown, most expensive first. Call this before a large batch to check remaining budget, or after a 402/429 to see what tripped. Every /api/generate, /api/v1/workflows/run and /api/try-on call carries this key's tenant. usedUsd includes in-flight (not yet settled) job estimates, so it can go DOWN as well as up: a job that later fails releases its estimate.",
|
|
3030
|
+
inputSchema: {},
|
|
3031
|
+
annotations: { readOnlyHint: true },
|
|
3032
|
+
handler: async (_args, ctx) => {
|
|
3033
|
+
return ctx.client.getUsage();
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
var studioListPublished = defineTool({
|
|
3037
|
+
name: "studio_list_published",
|
|
3038
|
+
description: "List every published recipe: { recipes: [{ slug, label, publishedRevision, updatedAt, hasEffects }] }. hasEffects tells you whether running it will create/attach an entity record (a character, a brand reference) on success, versus a pure generation recipe. Free, read-only.",
|
|
3039
|
+
inputSchema: {},
|
|
3040
|
+
annotations: { readOnlyHint: true },
|
|
3041
|
+
handler: async (_args, ctx) => ctx.client.listPublished()
|
|
3042
|
+
});
|
|
3043
|
+
var studioDescribePublished = defineTool({
|
|
3044
|
+
name: "studio_describe_published",
|
|
3045
|
+
description: "Describe one published recipe by slug: { slug, definition, hasEffects }. definition is the FROZEN workflow (params, node shapes) exactly as it was at publish time, never the live authoring document, which may have moved on since. Read this before studio_run_published to know what params it needs. Free, read-only.",
|
|
3046
|
+
inputSchema: {
|
|
3047
|
+
slug: z8.string().min(1).describe("The recipe slug, from studio_list_published.")
|
|
3048
|
+
},
|
|
3049
|
+
annotations: { readOnlyHint: true },
|
|
3050
|
+
handler: async (args, ctx) => {
|
|
3051
|
+
const res = await ctx.client.describePublished(args.slug);
|
|
3052
|
+
return res;
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
3055
|
+
var studioRunPublished = defineTool({
|
|
3056
|
+
name: "studio_run_published",
|
|
3057
|
+
description: `Run a published recipe by slug. Runs its FROZEN definition (studio_describe_published shows exactly what it needs); params fill the definition's own {kind:"param"} bindings. On success, the recipe's declared post-run effects fire automatically (e.g. character minting creates the character and attaches its reference views); effects never fire on a failed or partial run, by design. Returns the runId plus per-node status, URLs and cost; with wait=true (default) it blocks until the run is terminal. Each node becomes a paid job. The same $10 confirm-gate as studio_run_workflow applies: an estimate at or over the threshold refuses with { code: "confirmation_required", estimate } unless confirm: true is set.`,
|
|
3058
|
+
inputSchema: {
|
|
3059
|
+
slug: z8.string().min(1).describe("The recipe slug, from studio_list_published."),
|
|
3060
|
+
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
3061
|
+
params: z8.record(z8.string(), z8.unknown()).optional().describe("Values for the recipe's params (see studio_describe_published)."),
|
|
3062
|
+
budgetUsd: z8.number().nonnegative().optional().describe(
|
|
3063
|
+
'Hard cap on the estimated cost, verified server-side before anything is created. An unverifiable estimate (a credits-only or unpriced executing node) refuses too. Refusal: { code: "budget_exceeded", estimate }.'
|
|
3064
|
+
),
|
|
3065
|
+
confirm: z8.boolean().optional().describe(
|
|
3066
|
+
'Bypasses the confirmation threshold for this one request (a flat $10 today) when the estimate meets or exceeds it. Without this, such a run refuses with { code: "confirmation_required", estimate }.'
|
|
3067
|
+
),
|
|
3068
|
+
wait: z8.boolean().default(true),
|
|
3069
|
+
waitSeconds
|
|
3070
|
+
},
|
|
3071
|
+
handler: async (args, ctx) => {
|
|
3072
|
+
const started = await ctx.client.runPublished(args.slug, {
|
|
3073
|
+
projectId: args.projectId,
|
|
3074
|
+
...args.params ? { params: args.params } : {},
|
|
3075
|
+
...args.budgetUsd !== void 0 ? { budgetUsd: args.budgetUsd } : {},
|
|
3076
|
+
...args.confirm !== void 0 ? { confirm: args.confirm } : {}
|
|
3077
|
+
});
|
|
3078
|
+
if (!args.wait) return { runId: started.runId, status: started.status };
|
|
3079
|
+
const run = await waitForRun(ctx.client, started.runId, pollOpts(ctx, args.waitSeconds));
|
|
3080
|
+
return projectRun(run);
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
var TOOLS = [
|
|
3084
|
+
studioListProjects,
|
|
3085
|
+
studioGenerateImage,
|
|
3086
|
+
studioGetJob,
|
|
3087
|
+
studioListCharacters,
|
|
3088
|
+
studioGetCharacter,
|
|
3089
|
+
studioCreateCharacter,
|
|
3090
|
+
studioMintCharacter,
|
|
3091
|
+
studioCreateBrand,
|
|
3092
|
+
studioUploadBrandReference,
|
|
3093
|
+
studioRunWorkflow,
|
|
3094
|
+
studioGetRun,
|
|
3095
|
+
studioDecideJudge,
|
|
3096
|
+
studioGetRunOutput,
|
|
3097
|
+
studioReplaceCharacterInVideo,
|
|
3098
|
+
studioGetSpend,
|
|
3099
|
+
studioListPublished,
|
|
3100
|
+
studioDescribePublished,
|
|
3101
|
+
studioRunPublished,
|
|
3102
|
+
studioGetUsage,
|
|
3103
|
+
...WORKFLOW_TOOLS
|
|
3104
|
+
];
|
|
3105
|
+
var TOOL_NAMES = TOOLS.map((t) => t.name);
|
|
3106
|
+
function toolResult(value) {
|
|
3107
|
+
const text = JSON.stringify(value, null, 2);
|
|
3108
|
+
return {
|
|
3109
|
+
content: [{ type: "text", text }],
|
|
3110
|
+
structuredContent: value && typeof value === "object" && !Array.isArray(value) ? value : { value }
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
function toolError(message) {
|
|
3114
|
+
return {
|
|
3115
|
+
isError: true,
|
|
3116
|
+
content: [{ type: "text", text: message }]
|
|
3117
|
+
};
|
|
3118
|
+
}
|
|
3119
|
+
async function invokeTool(def, args, ctx) {
|
|
3120
|
+
try {
|
|
3121
|
+
const parsed = z8.object(def.inputSchema).parse(args ?? {});
|
|
3122
|
+
const value = await def.handler(parsed, ctx);
|
|
3123
|
+
return toolResult(value);
|
|
3124
|
+
} catch (err) {
|
|
3125
|
+
return toolError(ctx.client.redact(errorMessage(err)));
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
function registerTools(server, ctx) {
|
|
3129
|
+
for (const def of TOOLS) {
|
|
3130
|
+
server.registerTool(
|
|
3131
|
+
def.name,
|
|
3132
|
+
{
|
|
3133
|
+
description: def.description,
|
|
3134
|
+
inputSchema: def.inputSchema,
|
|
3135
|
+
...def.annotations ? { annotations: def.annotations } : {}
|
|
3136
|
+
},
|
|
3137
|
+
(async (args) => invokeTool(def, args, ctx))
|
|
3138
|
+
);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// src/server.ts
|
|
3143
|
+
var SERVER_NAME = "lumitra-studio";
|
|
3144
|
+
var SERVER_VERSION = "0.1.0";
|
|
3145
|
+
function createStudioMcpServer(opts = {}) {
|
|
3146
|
+
const config = opts.config ?? resolveConfig(opts.env ?? process.env);
|
|
3147
|
+
const client = new StudioClient(config, {
|
|
3148
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
3149
|
+
userAgent: `${SERVER_NAME}-mcp/${SERVER_VERSION}`
|
|
3150
|
+
});
|
|
3151
|
+
const server = new McpServer(
|
|
3152
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
3153
|
+
{
|
|
3154
|
+
instructions: [
|
|
3155
|
+
"Lumitra Studio: brand-consistent image generation, character minting, workflow runs and the still-swap video recipe over the hosted Studio API.",
|
|
3156
|
+
"Start with studio_list_projects to get a projectId. Every generation costs real money (roughly $0.04 per image, $0.30 per character mint, $0.70 per video take); check studio_get_spend when in doubt.",
|
|
3157
|
+
"Long jobs: tools wait by default (up to timeoutSeconds, 600 by default) and return ids you can poll with studio_get_job / studio_get_run if they time out.",
|
|
3158
|
+
"Authoring a workflow is free and revisioned: studio_workflow_describe for the vocabulary, studio_workflow_create to build one from ops, studio_workflow_apply to change it on top of a baseRevision (a stale_revision result means re-read, rebase, retry), studio_workflow_validate to dry-run before spending, then studio_run_workflow with its savedId."
|
|
3159
|
+
].join(" ")
|
|
3160
|
+
}
|
|
3161
|
+
);
|
|
3162
|
+
registerTools(server, { client, ...opts.poll ? { poll: opts.poll } : {} });
|
|
3163
|
+
return { server, client };
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
// src/cli.ts
|
|
3167
|
+
import { createServer as createHttpServer } from "http";
|
|
3168
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3169
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3170
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3171
|
+
function parseArgs(argv) {
|
|
3172
|
+
const args = { http: false, port: 3939, host: "127.0.0.1", help: false, version: false };
|
|
3173
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3174
|
+
const a = argv[i];
|
|
3175
|
+
if (a === "--http") args.http = true;
|
|
3176
|
+
else if (a === "--port" || a === "-p") {
|
|
3177
|
+
const v = Number(argv[++i]);
|
|
3178
|
+
if (!Number.isInteger(v) || v < 0 || v > 65535) throw new Error(`invalid --port ${argv[i]}`);
|
|
3179
|
+
args.port = v;
|
|
3180
|
+
} else if (a.startsWith("--port=")) {
|
|
3181
|
+
const v = Number(a.slice("--port=".length));
|
|
3182
|
+
if (!Number.isInteger(v) || v < 0 || v > 65535) throw new Error(`invalid ${a}`);
|
|
3183
|
+
args.port = v;
|
|
3184
|
+
} else if (a === "--host") args.host = argv[++i] ?? args.host;
|
|
3185
|
+
else if (a.startsWith("--host=")) args.host = a.slice("--host=".length);
|
|
3186
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
3187
|
+
else if (a === "--version" || a === "-v") args.version = true;
|
|
3188
|
+
else throw new Error(`unknown argument ${a}`);
|
|
3189
|
+
}
|
|
3190
|
+
return args;
|
|
3191
|
+
}
|
|
3192
|
+
var USAGE = `studio-mcp ${SERVER_VERSION}: MCP server for Lumitra Studio
|
|
3193
|
+
|
|
3194
|
+
Usage:
|
|
3195
|
+
studio-mcp stdio transport (default)
|
|
3196
|
+
studio-mcp --http [--port N] Streamable HTTP on http://127.0.0.1:N/mcp (default port 3939)
|
|
3197
|
+
studio-mcp --http --host 0.0.0.0 --port 3939
|
|
3198
|
+
|
|
3199
|
+
Environment:
|
|
3200
|
+
LUMITRA_STUDIO_API_KEY bearer token (required; legacy LUMITRA_STUDIO_SERVICE_TOKEN accepted)
|
|
3201
|
+
LUMITRA_STUDIO_BASE_URL default https://studio.lumitra.co
|
|
3202
|
+
|
|
3203
|
+
Tools: ${TOOL_NAMES.join(", ")}
|
|
3204
|
+
`;
|
|
3205
|
+
async function runStdio() {
|
|
3206
|
+
const { server } = createStudioMcpServer();
|
|
3207
|
+
const transport = new StdioServerTransport();
|
|
3208
|
+
await server.connect(transport);
|
|
3209
|
+
process.stderr.write(`[${SERVER_NAME}] stdio transport ready (${TOOL_NAMES.length} tools)
|
|
3210
|
+
`);
|
|
3211
|
+
}
|
|
3212
|
+
function createHttpHandler() {
|
|
3213
|
+
resolveConfig();
|
|
3214
|
+
return async (req, res) => {
|
|
3215
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3216
|
+
if (url.pathname === "/healthz") {
|
|
3217
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, name: SERVER_NAME, version: SERVER_VERSION }));
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
if (url.pathname !== "/mcp") {
|
|
3221
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "not found; use POST /mcp" }));
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
const { server } = createStudioMcpServer();
|
|
3225
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
3226
|
+
res.on("close", () => {
|
|
3227
|
+
void transport.close();
|
|
3228
|
+
void server.close();
|
|
3229
|
+
});
|
|
3230
|
+
try {
|
|
3231
|
+
await server.connect(transport);
|
|
3232
|
+
await transport.handleRequest(req, res);
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
process.stderr.write(`[${SERVER_NAME}] request failed: ${err instanceof Error ? err.message : String(err)}
|
|
3235
|
+
`);
|
|
3236
|
+
if (!res.headersSent) {
|
|
3237
|
+
res.writeHead(500, { "content-type": "application/json" }).end(
|
|
3238
|
+
JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "internal error" }, id: null })
|
|
3239
|
+
);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
async function runHttp(args) {
|
|
3245
|
+
const handler = createHttpHandler();
|
|
3246
|
+
const httpServer = createHttpServer((req, res) => void handler(req, res));
|
|
3247
|
+
await new Promise((resolvePromise, reject) => {
|
|
3248
|
+
httpServer.once("error", reject);
|
|
3249
|
+
httpServer.listen(args.port, args.host, () => resolvePromise());
|
|
3250
|
+
});
|
|
3251
|
+
const addr = httpServer.address();
|
|
3252
|
+
const port = typeof addr === "object" && addr ? addr.port : args.port;
|
|
3253
|
+
process.stderr.write(`[${SERVER_NAME}] Streamable HTTP on http://${args.host}:${port}/mcp (${TOOL_NAMES.length} tools, instance ${randomUUID2().slice(0, 8)})
|
|
3254
|
+
`);
|
|
3255
|
+
const shutdown = () => {
|
|
3256
|
+
httpServer.close(() => process.exit(0));
|
|
3257
|
+
setTimeout(() => process.exit(0), 2e3).unref();
|
|
3258
|
+
};
|
|
3259
|
+
process.on("SIGINT", shutdown);
|
|
3260
|
+
process.on("SIGTERM", shutdown);
|
|
3261
|
+
}
|
|
3262
|
+
async function main(argv = process.argv.slice(2)) {
|
|
3263
|
+
let args;
|
|
3264
|
+
try {
|
|
3265
|
+
args = parseArgs(argv);
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}
|
|
3268
|
+
|
|
3269
|
+
${USAGE}`);
|
|
3270
|
+
process.exit(2);
|
|
3271
|
+
}
|
|
3272
|
+
if (args.help) {
|
|
3273
|
+
process.stdout.write(USAGE);
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
if (args.version) {
|
|
3277
|
+
process.stdout.write(`${SERVER_VERSION}
|
|
3278
|
+
`);
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
try {
|
|
3282
|
+
if (args.http) await runHttp(args);
|
|
3283
|
+
else await runStdio();
|
|
3284
|
+
} catch (err) {
|
|
3285
|
+
if (err instanceof ConfigError) {
|
|
3286
|
+
process.stderr.write(`[${SERVER_NAME}] ${err.message}
|
|
3287
|
+
`);
|
|
3288
|
+
process.exit(1);
|
|
3289
|
+
}
|
|
3290
|
+
process.stderr.write(`[${SERVER_NAME}] fatal: ${err instanceof Error ? err.message : String(err)}
|
|
3291
|
+
`);
|
|
3292
|
+
process.exit(1);
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
var invokedDirectly = typeof process.argv[1] === "string" && /(^|[\\/])(cli\.(js|ts|mjs)|studio-mcp)$/.test(process.argv[1]);
|
|
3296
|
+
if (invokedDirectly) {
|
|
3297
|
+
void main();
|
|
3298
|
+
}
|
|
3299
|
+
export {
|
|
3300
|
+
CHARACTER_SHEET_DEFINITION,
|
|
3301
|
+
CHARACTER_SHEET_NODES,
|
|
3302
|
+
ConfigError,
|
|
3303
|
+
ContractError,
|
|
3304
|
+
DEFAULT_BASE_URL,
|
|
3305
|
+
DEFAULT_MOTION_PROMPT,
|
|
3306
|
+
DEFAULT_SWAP_EDIT_MODEL,
|
|
3307
|
+
DEFAULT_SWAP_PROMPT,
|
|
3308
|
+
DEFAULT_SWAP_VIDEO_MODEL,
|
|
3309
|
+
DEFAULT_TIMEOUT_MS,
|
|
3310
|
+
MINT_REFERENCE_CATEGORIES,
|
|
3311
|
+
PollTimeoutError,
|
|
3312
|
+
SERVER_NAME,
|
|
3313
|
+
SERVER_VERSION,
|
|
3314
|
+
SUMMARY_TEXT_CHARS,
|
|
3315
|
+
StudioApiError,
|
|
3316
|
+
StudioClient,
|
|
3317
|
+
TOOLS,
|
|
3318
|
+
TOOL_NAMES,
|
|
3319
|
+
ToolInputError,
|
|
3320
|
+
WORKFLOW_TOOLS,
|
|
3321
|
+
affectedNodes,
|
|
3322
|
+
buildCharacterSheetParams,
|
|
3323
|
+
buildCharacterSwapDefinition,
|
|
3324
|
+
categoryForNode,
|
|
3325
|
+
characterSheetDefinition,
|
|
3326
|
+
createHttpHandler,
|
|
3327
|
+
createStudioMcpServer,
|
|
3328
|
+
invokeTool,
|
|
3329
|
+
main,
|
|
3330
|
+
parseArgs,
|
|
3331
|
+
parseDocument,
|
|
3332
|
+
parseOps,
|
|
3333
|
+
pollUntil,
|
|
3334
|
+
projectionOf,
|
|
3335
|
+
redactSecret,
|
|
3336
|
+
registerTools,
|
|
3337
|
+
resolveConfig,
|
|
3338
|
+
seedFacePrompt,
|
|
3339
|
+
studioWorkflowApply,
|
|
3340
|
+
studioWorkflowCreate,
|
|
3341
|
+
studioWorkflowDescribe,
|
|
3342
|
+
studioWorkflowGet,
|
|
3343
|
+
studioWorkflowScreenshot,
|
|
3344
|
+
studioWorkflowValidate,
|
|
3345
|
+
summarizeDocument,
|
|
3346
|
+
summarizeNode,
|
|
3347
|
+
touchedNodeIds,
|
|
3348
|
+
turnaroundViewPrompt,
|
|
3349
|
+
waitForJob,
|
|
3350
|
+
waitForRun
|
|
3351
|
+
};
|
|
3352
|
+
//# sourceMappingURL=index.js.map
|