@marlinjai/studio-mcp 0.1.1 → 0.2.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/dist/cli.js +191 -35
- package/dist/index.d.ts +171 -5
- package/dist/index.js +193 -35
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -448,6 +448,10 @@ var MODEL_TASKS = [
|
|
|
448
448
|
"speech-to-speech",
|
|
449
449
|
"image-to-text",
|
|
450
450
|
"image-to-json",
|
|
451
|
+
// Camera-motion record (2026-09-13): a video in, a prompt-ready description
|
|
452
|
+
// of its camera motion out, measured from the frames by Studio's own code
|
|
453
|
+
// (model `studio/motion-record`, no paid provider).
|
|
454
|
+
"video-to-text",
|
|
451
455
|
// A human decision gate over candidate images (P3 J1). Still a model task,
|
|
452
456
|
// not a new node kind: WorkflowNode already requires task + model, and the
|
|
453
457
|
// ranking call underneath is the same image-to-json vision model
|
|
@@ -492,6 +496,8 @@ var TASK_PORTS = {
|
|
|
492
496
|
// VISION + LANGUAGE.
|
|
493
497
|
"image-to-text": { inputs: ["image", "text"], output: "text" },
|
|
494
498
|
"image-to-json": { inputs: ["image", "text"], output: "json" },
|
|
499
|
+
// Camera-motion record: the wired clip is the whole input, no prompt.
|
|
500
|
+
"video-to-text": { inputs: ["video"], output: "text" },
|
|
495
501
|
// Judge (P3 J1): one candidate wire in (the several results of ONE
|
|
496
502
|
// upstream node-run, not fan-in), the approved candidate out. Both sides
|
|
497
503
|
// are typed 'image' so an approved candidate can feed straight into any
|
|
@@ -555,11 +561,21 @@ var RefBindingSchema = z2.object({
|
|
|
555
561
|
// the "no array-of-refs" gap without widening the binding grammar itself.
|
|
556
562
|
wrap: z2.literal("array").optional()
|
|
557
563
|
});
|
|
558
|
-
var
|
|
564
|
+
var ListItemBindingSchema = z2.discriminatedUnion("kind", [
|
|
559
565
|
LiteralBindingSchema,
|
|
560
566
|
ParamBindingSchema,
|
|
561
567
|
RefBindingSchema
|
|
562
568
|
]);
|
|
569
|
+
var ListBindingSchema = z2.object({
|
|
570
|
+
kind: z2.literal("list"),
|
|
571
|
+
items: z2.array(ListItemBindingSchema)
|
|
572
|
+
});
|
|
573
|
+
var BindingSchema = z2.discriminatedUnion("kind", [
|
|
574
|
+
LiteralBindingSchema,
|
|
575
|
+
ParamBindingSchema,
|
|
576
|
+
RefBindingSchema,
|
|
577
|
+
ListBindingSchema
|
|
578
|
+
]);
|
|
563
579
|
var WorkflowTaskSchema = z2.enum(MODEL_TASKS);
|
|
564
580
|
var RetryPolicySchema = z2.object({
|
|
565
581
|
maxAttempts: z2.number().int().positive(),
|
|
@@ -681,6 +697,16 @@ var ImageSourceNodeSchema = z3.object({
|
|
|
681
697
|
label: LabelSchema.optional(),
|
|
682
698
|
collapsed: CollapsedSchema.optional()
|
|
683
699
|
});
|
|
700
|
+
var VideoSourceNodeSchema = z3.object({
|
|
701
|
+
id: IdSchema,
|
|
702
|
+
kind: z3.literal("video-source"),
|
|
703
|
+
videoUrl: UrlSchema.default(""),
|
|
704
|
+
/** The Storage Brain asset id, when known, so the clip can be re-signed and
|
|
705
|
+
* attributed the way an image source's asset is. */
|
|
706
|
+
assetId: AssetIdSchema.optional(),
|
|
707
|
+
label: LabelSchema.optional(),
|
|
708
|
+
collapsed: CollapsedSchema.optional()
|
|
709
|
+
});
|
|
684
710
|
var InputNodeSchema = z3.object({
|
|
685
711
|
id: IdSchema,
|
|
686
712
|
kind: z3.literal("input"),
|
|
@@ -701,6 +727,7 @@ var DocNodeSchema = z3.discriminatedUnion("kind", [
|
|
|
701
727
|
TaskNodeSchema,
|
|
702
728
|
PromptSourceNodeSchema,
|
|
703
729
|
ImageSourceNodeSchema,
|
|
730
|
+
VideoSourceNodeSchema,
|
|
704
731
|
InputNodeSchema
|
|
705
732
|
]);
|
|
706
733
|
var DocEdgeSchema = z3.object({
|
|
@@ -987,7 +1014,7 @@ var MODEL_CATALOG = [
|
|
|
987
1014
|
label: "Kling 2.5 Turbo Pro (image-to-video)",
|
|
988
1015
|
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.",
|
|
989
1016
|
cost: { usd: 0.35 },
|
|
990
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1017
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true },
|
|
991
1018
|
isDefault: true
|
|
992
1019
|
},
|
|
993
1020
|
{
|
|
@@ -1008,7 +1035,7 @@ var MODEL_CATALOG = [
|
|
|
1008
1035
|
label: "Seedance 2.5 (image-to-video)",
|
|
1009
1036
|
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.",
|
|
1010
1037
|
cost: { usd: 1.89 },
|
|
1011
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
1038
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
|
|
1012
1039
|
},
|
|
1013
1040
|
{
|
|
1014
1041
|
id: "fal/seedance-2-0-image-to-video",
|
|
@@ -1018,7 +1045,7 @@ var MODEL_CATALOG = [
|
|
|
1018
1045
|
label: "Seedance 2.0 (image-to-video)",
|
|
1019
1046
|
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.",
|
|
1020
1047
|
cost: { usd: 1.89 },
|
|
1021
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
1048
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
|
|
1022
1049
|
},
|
|
1023
1050
|
// ── FLUX.3 (Black Forest Labs) ───────────────────────────────────────────
|
|
1024
1051
|
// One model, three tiers of the same three input shapes:
|
|
@@ -1053,7 +1080,7 @@ var MODEL_CATALOG = [
|
|
|
1053
1080
|
label: "FLUX.3 draft (first + last frame)",
|
|
1054
1081
|
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.",
|
|
1055
1082
|
cost: { usd: 0.3 },
|
|
1056
|
-
capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true }
|
|
1083
|
+
capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true, acceptsEndFrame: true }
|
|
1057
1084
|
},
|
|
1058
1085
|
{
|
|
1059
1086
|
id: "fal/flux-3-keyframes-to-video-draft",
|
|
@@ -1083,7 +1110,7 @@ var MODEL_CATALOG = [
|
|
|
1083
1110
|
label: "FLUX.3 (first + last frame)",
|
|
1084
1111
|
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.",
|
|
1085
1112
|
cost: { usd: 0.85 },
|
|
1086
|
-
capabilities: { needsImageInput: true, maxInputImages: 2 }
|
|
1113
|
+
capabilities: { needsImageInput: true, maxInputImages: 2, acceptsEndFrame: true }
|
|
1087
1114
|
},
|
|
1088
1115
|
{
|
|
1089
1116
|
id: "fal/flux-3-keyframes-to-video",
|
|
@@ -1153,6 +1180,19 @@ var MODEL_CATALOG = [
|
|
|
1153
1180
|
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1154
1181
|
isDefault: true
|
|
1155
1182
|
},
|
|
1183
|
+
// Camera-motion record (2026-09-13): Studio's own analysis, no paid provider.
|
|
1184
|
+
// A video in, a prompt-ready description of its camera motion out
|
|
1185
|
+
// (job.output.text), with the measured record beside it (job.output.json).
|
|
1186
|
+
{
|
|
1187
|
+
id: "studio/motion-record",
|
|
1188
|
+
provider: "studio",
|
|
1189
|
+
modality: "text",
|
|
1190
|
+
task: "video-to-text",
|
|
1191
|
+
label: "Motion record (Studio)",
|
|
1192
|
+
description: "Measures a clip's camera motion from its frames (phase correlation every 0.1 s: pan, tilt, dolly, steadiness, subject motion) and writes a deterministic description to job.output.text plus the full record to job.output.json. Runs in Studio, costs nothing. Default video-to-text.",
|
|
1193
|
+
cost: { usd: 0 },
|
|
1194
|
+
isDefault: true
|
|
1195
|
+
},
|
|
1156
1196
|
{
|
|
1157
1197
|
id: "fal/moondream-next-vqa",
|
|
1158
1198
|
provider: "fal",
|
|
@@ -1344,6 +1384,9 @@ function portBinding(task, port) {
|
|
|
1344
1384
|
if (port === "mesh3d" && (task === "remesh" || task === "texture")) {
|
|
1345
1385
|
return { key: "parentAssetId", path: "assetId" };
|
|
1346
1386
|
}
|
|
1387
|
+
if (port === "video" && task === "video-to-text") {
|
|
1388
|
+
return { key: "inputVideoUrl", path: "url" };
|
|
1389
|
+
}
|
|
1347
1390
|
if (port === "video" && task === "video-to-video") {
|
|
1348
1391
|
return { key: "inputVideoUrl", path: "url" };
|
|
1349
1392
|
}
|
|
@@ -1374,6 +1417,30 @@ function canConnect(sourceOut, targetTask, targetPort) {
|
|
|
1374
1417
|
if (sourceOut !== targetPort) return false;
|
|
1375
1418
|
return portBinding(targetTask, targetPort) !== null;
|
|
1376
1419
|
}
|
|
1420
|
+
function portCardinality(task, port) {
|
|
1421
|
+
if (portBinding(task, port) === null) return "none";
|
|
1422
|
+
if (port === "image" && task === "image-edit") return "many";
|
|
1423
|
+
if (port === "image" && task === "image-to-video") return "two";
|
|
1424
|
+
return "one";
|
|
1425
|
+
}
|
|
1426
|
+
function cardinalityLimit(cardinality) {
|
|
1427
|
+
switch (cardinality) {
|
|
1428
|
+
case "none":
|
|
1429
|
+
return 0;
|
|
1430
|
+
case "one":
|
|
1431
|
+
return 1;
|
|
1432
|
+
case "two":
|
|
1433
|
+
return 2;
|
|
1434
|
+
case "many":
|
|
1435
|
+
return Number.POSITIVE_INFINITY;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function orderedPortKey(task, port, ordinal) {
|
|
1439
|
+
const canonical = portBinding(task, port);
|
|
1440
|
+
if (!canonical) return null;
|
|
1441
|
+
if (portCardinality(task, port) === "two" && ordinal === 1) return "endImageUrl";
|
|
1442
|
+
return canonical.key;
|
|
1443
|
+
}
|
|
1377
1444
|
function sourcePortOf(node) {
|
|
1378
1445
|
switch (node.kind) {
|
|
1379
1446
|
case "task":
|
|
@@ -1382,10 +1449,28 @@ function sourcePortOf(node) {
|
|
|
1382
1449
|
return "text";
|
|
1383
1450
|
case "image-source":
|
|
1384
1451
|
return "image";
|
|
1452
|
+
case "video-source":
|
|
1453
|
+
return "video";
|
|
1385
1454
|
case "input":
|
|
1386
1455
|
return node.type;
|
|
1387
1456
|
}
|
|
1388
1457
|
}
|
|
1458
|
+
function targetCardinality(node, port) {
|
|
1459
|
+
if (node.kind !== "task") return "none";
|
|
1460
|
+
return portCardinality(node.task, port);
|
|
1461
|
+
}
|
|
1462
|
+
function slotRefusal(node, port, wired) {
|
|
1463
|
+
const cardinality = targetCardinality(node, port);
|
|
1464
|
+
let limit = cardinalityLimit(cardinality);
|
|
1465
|
+
if (cardinality === "many" && node.kind === "task" && node.model) {
|
|
1466
|
+
const cap = tryGetModel(node.model)?.capabilities?.maxInputImages;
|
|
1467
|
+
if (typeof cap === "number" && cap > 0) limit = Math.min(limit, cap);
|
|
1468
|
+
}
|
|
1469
|
+
if (wired < limit) return null;
|
|
1470
|
+
if (cardinality === "one") return `node "${node.id}" accepts one ${port} wire, got more`;
|
|
1471
|
+
if (cardinality === "two") return `node "${node.id}" takes two ${port} wires (start frame, end frame), got more`;
|
|
1472
|
+
return `node "${node.id}" (${node.kind === "task" ? node.model : node.kind}) takes at most ${limit} ${port} wires`;
|
|
1473
|
+
}
|
|
1389
1474
|
function wireLegality(source, target, port) {
|
|
1390
1475
|
if (source.id === target.id) return { ok: false, code: "cycle", message: `node "${source.id}" cannot wire into itself` };
|
|
1391
1476
|
const produced = sourcePortOf(source);
|
|
@@ -1452,7 +1537,7 @@ function validateDocument(doc) {
|
|
|
1452
1537
|
inputNames.add(node.name);
|
|
1453
1538
|
}
|
|
1454
1539
|
const edgeKeys = /* @__PURE__ */ new Set();
|
|
1455
|
-
const slots = /* @__PURE__ */ new
|
|
1540
|
+
const slots = /* @__PURE__ */ new Map();
|
|
1456
1541
|
for (const e of doc.edges) {
|
|
1457
1542
|
const key = edgeKey(e);
|
|
1458
1543
|
if (edgeKeys.has(key)) return { ok: false, code: "conflict", message: `duplicate edge ${key}` };
|
|
@@ -1464,14 +1549,24 @@ function validateDocument(doc) {
|
|
|
1464
1549
|
const legal = wireLegality(source, target, e.port);
|
|
1465
1550
|
if (!legal.ok) return legal;
|
|
1466
1551
|
const slot = slotKey(e.target, e.port);
|
|
1467
|
-
|
|
1552
|
+
const wired = slots.get(slot) ?? 0;
|
|
1553
|
+
const refusal = slotRefusal(target, e.port, wired);
|
|
1554
|
+
if (refusal) return { ok: false, code: "cardinality", message: refusal };
|
|
1555
|
+
slots.set(slot, wired + 1);
|
|
1556
|
+
}
|
|
1557
|
+
for (const [slot, wired] of slots) {
|
|
1558
|
+
if (wired < 2) continue;
|
|
1559
|
+
const [targetId, port] = JSON.parse(slot);
|
|
1560
|
+
const target = byId.get(targetId);
|
|
1561
|
+
if (!target || target.kind !== "task" || port !== "image" || portCardinality(target.task, port) !== "two" || !target.model) continue;
|
|
1562
|
+
const model = tryGetModel(target.model);
|
|
1563
|
+
if (model && !model.capabilities?.acceptsEndFrame) {
|
|
1468
1564
|
return {
|
|
1469
1565
|
ok: false,
|
|
1470
|
-
code: "
|
|
1471
|
-
message: `
|
|
1566
|
+
code: "invalid_port",
|
|
1567
|
+
message: `"${target.id}" (${target.model}) takes no end frame, so its second image wire has nowhere to bind. Pick a model with start and end frame support (Kling 2.5, Seedance 2.x, FLUX.3 first-last) or disconnect it.`
|
|
1472
1568
|
};
|
|
1473
1569
|
}
|
|
1474
|
-
slots.add(slot);
|
|
1475
1570
|
}
|
|
1476
1571
|
if (documentTopologicalOrder(doc) === null) {
|
|
1477
1572
|
return { ok: false, code: "cycle", message: `document "${doc.id}" has a cycle in its wires` };
|
|
@@ -1518,6 +1613,12 @@ var ImageSourcePropsSchema = z4.object({
|
|
|
1518
1613
|
label: LabelSchema.nullable(),
|
|
1519
1614
|
collapsed: CollapsedSchema.nullable()
|
|
1520
1615
|
}).partial().strict();
|
|
1616
|
+
var VideoSourcePropsSchema = z4.object({
|
|
1617
|
+
videoUrl: UrlSchema,
|
|
1618
|
+
assetId: AssetIdSchema.nullable(),
|
|
1619
|
+
label: LabelSchema.nullable(),
|
|
1620
|
+
collapsed: CollapsedSchema.nullable()
|
|
1621
|
+
}).partial().strict();
|
|
1521
1622
|
var InputNodePropsSchema = z4.object({
|
|
1522
1623
|
name: InputNameSchema,
|
|
1523
1624
|
type: InputTypeSchema,
|
|
@@ -1555,6 +1656,7 @@ var SetNodePropsOpSchema = z4.discriminatedUnion("kind", [
|
|
|
1555
1656
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("task"), id: IdSchema, props: TaskNodePropsSchema }),
|
|
1556
1657
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("prompt-source"), id: IdSchema, props: PromptSourcePropsSchema }),
|
|
1557
1658
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("image-source"), id: IdSchema, props: ImageSourcePropsSchema }),
|
|
1659
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("video-source"), id: IdSchema, props: VideoSourcePropsSchema }),
|
|
1558
1660
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("input"), id: IdSchema, props: InputNodePropsSchema })
|
|
1559
1661
|
]);
|
|
1560
1662
|
var SetNodeSettingOpSchema = z4.object({
|
|
@@ -1586,8 +1688,8 @@ var DisconnectOpSchema = z4.object({
|
|
|
1586
1688
|
});
|
|
1587
1689
|
var ExposeAsInputOpSchema = z4.object({
|
|
1588
1690
|
type: z4.literal("exposeAsInput"),
|
|
1589
|
-
/** A prompt-source or
|
|
1590
|
-
* (same id, same produced port type), so its wires survive. */
|
|
1691
|
+
/** A prompt-source, image-source or video-source node; it becomes an input
|
|
1692
|
+
* node IN PLACE (same id, same produced port type), so its wires survive. */
|
|
1591
1693
|
id: IdSchema,
|
|
1592
1694
|
name: InputNameSchema,
|
|
1593
1695
|
required: z4.boolean().optional(),
|
|
@@ -1600,8 +1702,9 @@ var ExposeAsInputOpSchema = z4.object({
|
|
|
1600
1702
|
});
|
|
1601
1703
|
var FreezeInputOpSchema = z4.object({
|
|
1602
1704
|
type: z4.literal("freezeInput"),
|
|
1603
|
-
/** A text or
|
|
1604
|
-
* PLACE carrying `value` (a chosen run's
|
|
1705
|
+
/** A text, image or video input node; it becomes a prompt-source /
|
|
1706
|
+
* image-source / video-source IN PLACE carrying `value` (a chosen run's
|
|
1707
|
+
* resolved value, or any literal). */
|
|
1605
1708
|
id: IdSchema,
|
|
1606
1709
|
value: PromptTextSchema,
|
|
1607
1710
|
assetId: AssetIdSchema.optional(),
|
|
@@ -1810,6 +1913,8 @@ var TASK_TO_KIND = {
|
|
|
1810
1913
|
// vision (text/json output, no binary asset)
|
|
1811
1914
|
"image-to-text": "analyze_image",
|
|
1812
1915
|
"image-to-json": "analyze_image",
|
|
1916
|
+
// camera-motion record (text + json output, no binary asset), Studio's own worker
|
|
1917
|
+
"video-to-text": "analyze_video",
|
|
1813
1918
|
// utility (new Image asset off the source)
|
|
1814
1919
|
upscale: "transform_image",
|
|
1815
1920
|
"background-remove": "transform_image",
|
|
@@ -1833,7 +1938,7 @@ function taskExecution(task) {
|
|
|
1833
1938
|
function paramTypeForInput(type) {
|
|
1834
1939
|
return type === "json" ? "json" : "string";
|
|
1835
1940
|
}
|
|
1836
|
-
function
|
|
1941
|
+
function isUsableSourceUrl(url) {
|
|
1837
1942
|
return url.length > 0 && !url.startsWith("data:");
|
|
1838
1943
|
}
|
|
1839
1944
|
function documentToDefinition(doc, opts = {}) {
|
|
@@ -1891,22 +1996,50 @@ function documentToDefinition(doc, opts = {}) {
|
|
|
1891
1996
|
issues.push({ code: "prompt_required", nodeId: node.id, message: `Node "${node.id}" (${task}) needs a prompt.` });
|
|
1892
1997
|
}
|
|
1893
1998
|
}
|
|
1999
|
+
const byPort = /* @__PURE__ */ new Map();
|
|
1894
2000
|
for (const e of wires) {
|
|
1895
2001
|
if (e.port === "text") continue;
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2002
|
+
const list = byPort.get(e.port);
|
|
2003
|
+
if (list) list.push(e);
|
|
2004
|
+
else byPort.set(e.port, [e]);
|
|
2005
|
+
}
|
|
2006
|
+
for (const [port, portWires] of byPort) {
|
|
2007
|
+
const b = portBinding(task, port);
|
|
2008
|
+
const cardinality = portCardinality(task, port);
|
|
2009
|
+
const items = [];
|
|
2010
|
+
let uploading = false;
|
|
2011
|
+
for (const [ordinal, e] of portWires.entries()) {
|
|
2012
|
+
const source = byId.get(e.source);
|
|
2013
|
+
const single = cardinality !== "many" || portWires.length === 1;
|
|
2014
|
+
const key = orderedPortKey(task, port, ordinal) ?? b.key;
|
|
2015
|
+
const wrap = single && b.wrap === "array";
|
|
2016
|
+
let item = null;
|
|
2017
|
+
if (source.kind === "image-source") {
|
|
2018
|
+
const url = source.imageUrl.trim();
|
|
2019
|
+
if (!isUsableSourceUrl(url)) {
|
|
2020
|
+
issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
|
|
2021
|
+
uploading = true;
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
item = { kind: "literal", value: wrap ? [url] : url };
|
|
2025
|
+
} else if (source.kind === "video-source") {
|
|
2026
|
+
const clip = source.videoUrl.trim();
|
|
2027
|
+
if (!isUsableSourceUrl(clip)) {
|
|
2028
|
+
issues.push({ code: "source_uploading", nodeId: node.id, message: `Video source for "${node.id}" is still uploading; wait for it to finish.` });
|
|
2029
|
+
uploading = true;
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
item = { kind: "literal", value: wrap ? [clip] : clip };
|
|
2033
|
+
} else if (source.kind === "input") {
|
|
2034
|
+
item = wrap && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
|
|
2035
|
+
} else if (source.kind === "task") {
|
|
2036
|
+
item = wrap ? { kind: "ref", node: source.id, path: b.path, wrap: "array" } : { kind: "ref", node: source.id, path: b.path };
|
|
1903
2037
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
} else if (source.kind === "task") {
|
|
1908
|
-
inputs[b.key] = b.wrap === "array" ? { kind: "ref", node: source.id, path: b.path, wrap: "array" } : { kind: "ref", node: source.id, path: b.path };
|
|
2038
|
+
if (!item) continue;
|
|
2039
|
+
if (single) inputs[key] = item;
|
|
2040
|
+
else items.push(item);
|
|
1909
2041
|
}
|
|
2042
|
+
if (!uploading && items.length > 0) inputs[b.key] = { kind: "list", items };
|
|
1910
2043
|
}
|
|
1911
2044
|
for (const [key, binding] of Object.entries(node.bakedInputs)) if (!(key in inputs)) inputs[key] = binding;
|
|
1912
2045
|
if (Object.keys(inputs).length === 0 && !promptMissing) {
|
|
@@ -2141,8 +2274,24 @@ function runFailureSummary(res) {
|
|
|
2141
2274
|
var DEFAULT_SWAP_EDIT_MODEL = "fal/nano-banana-2-edit";
|
|
2142
2275
|
var DEFAULT_SWAP_VIDEO_MODEL = "fal/seedance-pro-image-to-video";
|
|
2143
2276
|
var KEYFRAME_SWAP_VIDEO_MODEL = "fal/kling-v2-5-image-to-video";
|
|
2144
|
-
var
|
|
2145
|
-
|
|
2277
|
+
var SWAP_SCOPES = ["identity", "full_body"];
|
|
2278
|
+
function composeSwapPrompt(options = {}) {
|
|
2279
|
+
const scope = options.scope ?? "identity";
|
|
2280
|
+
const taken = scope === "full_body" ? "take the face, head, hair and body proportions from the second image" : "take the face, head and hair from the second image";
|
|
2281
|
+
const kept = scope === "full_body" ? "Keep the first image's clothing, pose, expression, gaze and hand placement." : "Keep the first image's body, clothing, pose, expression, gaze and hand placement.";
|
|
2282
|
+
const sentences = [
|
|
2283
|
+
`Replace the person in the first image with the person in the second image: ${taken}.`,
|
|
2284
|
+
kept,
|
|
2285
|
+
"Match the first image's lighting, focus and colour, and keep its framing and background."
|
|
2286
|
+
];
|
|
2287
|
+
for (const raw of options.preserve ?? []) {
|
|
2288
|
+
const constraint = raw.trim().replace(/[.\s]+$/, "");
|
|
2289
|
+
if (constraint) sentences.push(`${constraint.charAt(0).toUpperCase()}${constraint.slice(1)}.`);
|
|
2290
|
+
}
|
|
2291
|
+
return sentences.join(" ");
|
|
2292
|
+
}
|
|
2293
|
+
var DEFAULT_SWAP_PROMPT = composeSwapPrompt();
|
|
2294
|
+
var DEFAULT_MOTION_PROMPT = "Continue the scene as photographed: keep the camera position and framing of the still, move the person naturally and continuously, and keep the lighting consistent. Add no camera movement.";
|
|
2146
2295
|
var SWAP_EDIT_NODE_ID = "edit";
|
|
2147
2296
|
var SWAP_VIDEO_NODE_ID = "video";
|
|
2148
2297
|
function defaultVideoModelFor(input) {
|
|
@@ -2162,7 +2311,7 @@ function buildCharacterSwapDefinition(input) {
|
|
|
2162
2311
|
task: "image-edit",
|
|
2163
2312
|
model: input.editModel ?? DEFAULT_SWAP_EDIT_MODEL,
|
|
2164
2313
|
inputs: {
|
|
2165
|
-
prompt: { kind: "literal", value: input.swapPrompt ??
|
|
2314
|
+
prompt: { kind: "literal", value: input.swapPrompt ?? composeSwapPrompt({ scope: input.scope, preserve: input.preserve }) },
|
|
2166
2315
|
inputImages: { kind: "literal", value: [input.sceneFrameUrl, input.characterImageUrl] },
|
|
2167
2316
|
aspectRatio: { kind: "literal", value: input.aspectRatio ?? "16:9" }
|
|
2168
2317
|
}
|
|
@@ -2271,6 +2420,9 @@ function summarizeNode(node, wires) {
|
|
|
2271
2420
|
case "image-source":
|
|
2272
2421
|
base.imageUrl = clipText(node.imageUrl);
|
|
2273
2422
|
break;
|
|
2423
|
+
case "video-source":
|
|
2424
|
+
base.videoUrl = clipText(node.videoUrl);
|
|
2425
|
+
break;
|
|
2274
2426
|
case "input":
|
|
2275
2427
|
base.name = node.name;
|
|
2276
2428
|
base.type = node.type;
|
|
@@ -2892,7 +3044,7 @@ var studioUploadBrandReference = defineTool({
|
|
|
2892
3044
|
}
|
|
2893
3045
|
});
|
|
2894
3046
|
var workflowDefinitionSchema = WorkflowDefinitionSchema.describe(
|
|
2895
|
-
'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"}
|
|
3047
|
+
'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"}, {kind:"ref",node,path:"url",wrap?:"array"} or an ordered {kind:"list",items:[...]} of those. Image-edit nodes take inputImages (an array: one wrapped ref/param, or a list in reference order, scene frame first); image-to-video nodes take inputImageUrl (start frame) and optionally endImageUrl (end frame, on models that accept one).'
|
|
2896
3048
|
);
|
|
2897
3049
|
var studioRunWorkflow = defineTool({
|
|
2898
3050
|
name: "studio_run_workflow",
|
|
@@ -2990,14 +3142,16 @@ var studioGetRunOutput = defineTool({
|
|
|
2990
3142
|
});
|
|
2991
3143
|
var studioReplaceCharacterInVideo = defineTool({
|
|
2992
3144
|
name: "studio_replace_character_in_video",
|
|
2993
|
-
description:
|
|
3145
|
+
description: 'Put a minted character into a video shot through the still-swap route: (1) an image-edit node (fal/nano-banana-2-edit) replaces the person in a scene FRAME with the character; (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). Be honest with the user about what this route is: the video step REGENERATES the motion from the stills, so the original performance, timing and background are recreated rather than preserved; when the original motion must survive exactly, say so before spending. Declare scope: identity (default) takes face, head and hair and keeps the footage body; full_body also takes the character body proportions, which changes the silhouette. Pass preserve with the two or three shot-specific constraints most likely to fail, from looking at the frame (e.g. "keep the open door in front of his body", "match the face soft focus"), or a complete swapPrompt of your own. Never add a realism suffix like "photorealistic" or a promise that every pixel stays identical; the model cannot keep either and the suffix sharpens deliberately soft subjects. 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). Inspect the still before paying for more takes, keep generated cuts under 5 seconds, and budget one take plus two retries per shot. Do not use video-to-video swap models on dark or handheld footage.',
|
|
2994
3146
|
inputSchema: {
|
|
2995
3147
|
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
2996
3148
|
sceneFrameUrl: httpUrl.describe("Public or signed URL of ONE frame of the target shot (png/jpg)."),
|
|
2997
3149
|
characterSlug: slug.optional().describe("Minted character to insert; its full-body reference (fallback three-quarter, face) is resolved automatically."),
|
|
2998
3150
|
characterReferenceUrl: httpUrl.optional().describe("Explicit character reference image URL (use instead of characterSlug)."),
|
|
2999
3151
|
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."),
|
|
3000
|
-
|
|
3152
|
+
scope: z8.enum(SWAP_SCOPES).optional().describe("How much of the person to replace. identity (default): face, head and hair from the character, the footage keeps its body. full_body: also the character body proportions; the silhouette changes, so the background behind the old body and every contact point must be re-derived."),
|
|
3153
|
+
preserve: z8.array(z8.string().min(1).max(200)).max(6).optional().describe('Shot-specific constraints most likely to fail, as concrete relationships seen in the frame, one per entry (e.g. "keep the open door in front of his body"). Appended to the composed prompt.'),
|
|
3154
|
+
swapPrompt: z8.string().optional().describe("A complete still-swap edit prompt of your own; replaces the composed one, so scope and preserve are then ignored."),
|
|
3001
3155
|
motionPrompt: z8.string().optional().describe("Override the motion description for the video node."),
|
|
3002
3156
|
videoModel: z8.string().optional().describe(`Image-to-video model id. Default ${DEFAULT_SWAP_VIDEO_MODEL}, or ${KEYFRAME_SWAP_VIDEO_MODEL} when endFrameUrl is given.`),
|
|
3003
3157
|
editModel: z8.string().default(DEFAULT_SWAP_EDIT_MODEL).describe("Image-edit model id for the still swap."),
|
|
@@ -3028,6 +3182,8 @@ var studioReplaceCharacterInVideo = defineTool({
|
|
|
3028
3182
|
characterImageUrl,
|
|
3029
3183
|
...args.endFrameUrl ? { endFrameUrl: args.endFrameUrl } : {},
|
|
3030
3184
|
...args.swapPrompt ? { swapPrompt: args.swapPrompt } : {},
|
|
3185
|
+
...args.scope ? { scope: args.scope } : {},
|
|
3186
|
+
...args.preserve ? { preserve: args.preserve } : {},
|
|
3031
3187
|
...args.motionPrompt ? { motionPrompt: args.motionPrompt } : {},
|
|
3032
3188
|
editModel: args.editModel,
|
|
3033
3189
|
videoModel,
|
|
@@ -3190,7 +3346,7 @@ function registerTools(server, ctx) {
|
|
|
3190
3346
|
|
|
3191
3347
|
// src/server.ts
|
|
3192
3348
|
var SERVER_NAME = "lumitra-studio";
|
|
3193
|
-
var SERVER_VERSION = "0.
|
|
3349
|
+
var SERVER_VERSION = "0.2.0";
|
|
3194
3350
|
function createStudioMcpServer(opts = {}) {
|
|
3195
3351
|
const config = opts.config ?? resolveConfig(opts.env ?? process.env);
|
|
3196
3352
|
const client = new StudioClient(config, {
|