@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/index.js
CHANGED
|
@@ -440,6 +440,10 @@ var MODEL_TASKS = [
|
|
|
440
440
|
"speech-to-speech",
|
|
441
441
|
"image-to-text",
|
|
442
442
|
"image-to-json",
|
|
443
|
+
// Camera-motion record (2026-09-13): a video in, a prompt-ready description
|
|
444
|
+
// of its camera motion out, measured from the frames by Studio's own code
|
|
445
|
+
// (model `studio/motion-record`, no paid provider).
|
|
446
|
+
"video-to-text",
|
|
443
447
|
// A human decision gate over candidate images (P3 J1). Still a model task,
|
|
444
448
|
// not a new node kind: WorkflowNode already requires task + model, and the
|
|
445
449
|
// ranking call underneath is the same image-to-json vision model
|
|
@@ -484,6 +488,8 @@ var TASK_PORTS = {
|
|
|
484
488
|
// VISION + LANGUAGE.
|
|
485
489
|
"image-to-text": { inputs: ["image", "text"], output: "text" },
|
|
486
490
|
"image-to-json": { inputs: ["image", "text"], output: "json" },
|
|
491
|
+
// Camera-motion record: the wired clip is the whole input, no prompt.
|
|
492
|
+
"video-to-text": { inputs: ["video"], output: "text" },
|
|
487
493
|
// Judge (P3 J1): one candidate wire in (the several results of ONE
|
|
488
494
|
// upstream node-run, not fan-in), the approved candidate out. Both sides
|
|
489
495
|
// are typed 'image' so an approved candidate can feed straight into any
|
|
@@ -547,11 +553,21 @@ var RefBindingSchema = z2.object({
|
|
|
547
553
|
// the "no array-of-refs" gap without widening the binding grammar itself.
|
|
548
554
|
wrap: z2.literal("array").optional()
|
|
549
555
|
});
|
|
550
|
-
var
|
|
556
|
+
var ListItemBindingSchema = z2.discriminatedUnion("kind", [
|
|
551
557
|
LiteralBindingSchema,
|
|
552
558
|
ParamBindingSchema,
|
|
553
559
|
RefBindingSchema
|
|
554
560
|
]);
|
|
561
|
+
var ListBindingSchema = z2.object({
|
|
562
|
+
kind: z2.literal("list"),
|
|
563
|
+
items: z2.array(ListItemBindingSchema)
|
|
564
|
+
});
|
|
565
|
+
var BindingSchema = z2.discriminatedUnion("kind", [
|
|
566
|
+
LiteralBindingSchema,
|
|
567
|
+
ParamBindingSchema,
|
|
568
|
+
RefBindingSchema,
|
|
569
|
+
ListBindingSchema
|
|
570
|
+
]);
|
|
555
571
|
var WorkflowTaskSchema = z2.enum(MODEL_TASKS);
|
|
556
572
|
var RetryPolicySchema = z2.object({
|
|
557
573
|
maxAttempts: z2.number().int().positive(),
|
|
@@ -673,6 +689,16 @@ var ImageSourceNodeSchema = z3.object({
|
|
|
673
689
|
label: LabelSchema.optional(),
|
|
674
690
|
collapsed: CollapsedSchema.optional()
|
|
675
691
|
});
|
|
692
|
+
var VideoSourceNodeSchema = z3.object({
|
|
693
|
+
id: IdSchema,
|
|
694
|
+
kind: z3.literal("video-source"),
|
|
695
|
+
videoUrl: UrlSchema.default(""),
|
|
696
|
+
/** The Storage Brain asset id, when known, so the clip can be re-signed and
|
|
697
|
+
* attributed the way an image source's asset is. */
|
|
698
|
+
assetId: AssetIdSchema.optional(),
|
|
699
|
+
label: LabelSchema.optional(),
|
|
700
|
+
collapsed: CollapsedSchema.optional()
|
|
701
|
+
});
|
|
676
702
|
var InputNodeSchema = z3.object({
|
|
677
703
|
id: IdSchema,
|
|
678
704
|
kind: z3.literal("input"),
|
|
@@ -693,6 +719,7 @@ var DocNodeSchema = z3.discriminatedUnion("kind", [
|
|
|
693
719
|
TaskNodeSchema,
|
|
694
720
|
PromptSourceNodeSchema,
|
|
695
721
|
ImageSourceNodeSchema,
|
|
722
|
+
VideoSourceNodeSchema,
|
|
696
723
|
InputNodeSchema
|
|
697
724
|
]);
|
|
698
725
|
var DocEdgeSchema = z3.object({
|
|
@@ -979,7 +1006,7 @@ var MODEL_CATALOG = [
|
|
|
979
1006
|
label: "Kling 2.5 Turbo Pro (image-to-video)",
|
|
980
1007
|
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.",
|
|
981
1008
|
cost: { usd: 0.35 },
|
|
982
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1009
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true },
|
|
983
1010
|
isDefault: true
|
|
984
1011
|
},
|
|
985
1012
|
{
|
|
@@ -1000,7 +1027,7 @@ var MODEL_CATALOG = [
|
|
|
1000
1027
|
label: "Seedance 2.5 (image-to-video)",
|
|
1001
1028
|
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.",
|
|
1002
1029
|
cost: { usd: 1.89 },
|
|
1003
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
1030
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
|
|
1004
1031
|
},
|
|
1005
1032
|
{
|
|
1006
1033
|
id: "fal/seedance-2-0-image-to-video",
|
|
@@ -1010,7 +1037,7 @@ var MODEL_CATALOG = [
|
|
|
1010
1037
|
label: "Seedance 2.0 (image-to-video)",
|
|
1011
1038
|
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.",
|
|
1012
1039
|
cost: { usd: 1.89 },
|
|
1013
|
-
capabilities: { needsImageInput: true, maxInputImages: 1 }
|
|
1040
|
+
capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
|
|
1014
1041
|
},
|
|
1015
1042
|
// ── FLUX.3 (Black Forest Labs) ───────────────────────────────────────────
|
|
1016
1043
|
// One model, three tiers of the same three input shapes:
|
|
@@ -1045,7 +1072,7 @@ var MODEL_CATALOG = [
|
|
|
1045
1072
|
label: "FLUX.3 draft (first + last frame)",
|
|
1046
1073
|
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.",
|
|
1047
1074
|
cost: { usd: 0.3 },
|
|
1048
|
-
capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true }
|
|
1075
|
+
capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true, acceptsEndFrame: true }
|
|
1049
1076
|
},
|
|
1050
1077
|
{
|
|
1051
1078
|
id: "fal/flux-3-keyframes-to-video-draft",
|
|
@@ -1075,7 +1102,7 @@ var MODEL_CATALOG = [
|
|
|
1075
1102
|
label: "FLUX.3 (first + last frame)",
|
|
1076
1103
|
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.",
|
|
1077
1104
|
cost: { usd: 0.85 },
|
|
1078
|
-
capabilities: { needsImageInput: true, maxInputImages: 2 }
|
|
1105
|
+
capabilities: { needsImageInput: true, maxInputImages: 2, acceptsEndFrame: true }
|
|
1079
1106
|
},
|
|
1080
1107
|
{
|
|
1081
1108
|
id: "fal/flux-3-keyframes-to-video",
|
|
@@ -1145,6 +1172,19 @@ var MODEL_CATALOG = [
|
|
|
1145
1172
|
capabilities: { needsImageInput: true, maxInputImages: 1 },
|
|
1146
1173
|
isDefault: true
|
|
1147
1174
|
},
|
|
1175
|
+
// Camera-motion record (2026-09-13): Studio's own analysis, no paid provider.
|
|
1176
|
+
// A video in, a prompt-ready description of its camera motion out
|
|
1177
|
+
// (job.output.text), with the measured record beside it (job.output.json).
|
|
1178
|
+
{
|
|
1179
|
+
id: "studio/motion-record",
|
|
1180
|
+
provider: "studio",
|
|
1181
|
+
modality: "text",
|
|
1182
|
+
task: "video-to-text",
|
|
1183
|
+
label: "Motion record (Studio)",
|
|
1184
|
+
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.",
|
|
1185
|
+
cost: { usd: 0 },
|
|
1186
|
+
isDefault: true
|
|
1187
|
+
},
|
|
1148
1188
|
{
|
|
1149
1189
|
id: "fal/moondream-next-vqa",
|
|
1150
1190
|
provider: "fal",
|
|
@@ -1336,6 +1376,9 @@ function portBinding(task, port) {
|
|
|
1336
1376
|
if (port === "mesh3d" && (task === "remesh" || task === "texture")) {
|
|
1337
1377
|
return { key: "parentAssetId", path: "assetId" };
|
|
1338
1378
|
}
|
|
1379
|
+
if (port === "video" && task === "video-to-text") {
|
|
1380
|
+
return { key: "inputVideoUrl", path: "url" };
|
|
1381
|
+
}
|
|
1339
1382
|
if (port === "video" && task === "video-to-video") {
|
|
1340
1383
|
return { key: "inputVideoUrl", path: "url" };
|
|
1341
1384
|
}
|
|
@@ -1366,6 +1409,30 @@ function canConnect(sourceOut, targetTask, targetPort) {
|
|
|
1366
1409
|
if (sourceOut !== targetPort) return false;
|
|
1367
1410
|
return portBinding(targetTask, targetPort) !== null;
|
|
1368
1411
|
}
|
|
1412
|
+
function portCardinality(task, port) {
|
|
1413
|
+
if (portBinding(task, port) === null) return "none";
|
|
1414
|
+
if (port === "image" && task === "image-edit") return "many";
|
|
1415
|
+
if (port === "image" && task === "image-to-video") return "two";
|
|
1416
|
+
return "one";
|
|
1417
|
+
}
|
|
1418
|
+
function cardinalityLimit(cardinality) {
|
|
1419
|
+
switch (cardinality) {
|
|
1420
|
+
case "none":
|
|
1421
|
+
return 0;
|
|
1422
|
+
case "one":
|
|
1423
|
+
return 1;
|
|
1424
|
+
case "two":
|
|
1425
|
+
return 2;
|
|
1426
|
+
case "many":
|
|
1427
|
+
return Number.POSITIVE_INFINITY;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
function orderedPortKey(task, port, ordinal) {
|
|
1431
|
+
const canonical = portBinding(task, port);
|
|
1432
|
+
if (!canonical) return null;
|
|
1433
|
+
if (portCardinality(task, port) === "two" && ordinal === 1) return "endImageUrl";
|
|
1434
|
+
return canonical.key;
|
|
1435
|
+
}
|
|
1369
1436
|
function sourcePortOf(node) {
|
|
1370
1437
|
switch (node.kind) {
|
|
1371
1438
|
case "task":
|
|
@@ -1374,10 +1441,28 @@ function sourcePortOf(node) {
|
|
|
1374
1441
|
return "text";
|
|
1375
1442
|
case "image-source":
|
|
1376
1443
|
return "image";
|
|
1444
|
+
case "video-source":
|
|
1445
|
+
return "video";
|
|
1377
1446
|
case "input":
|
|
1378
1447
|
return node.type;
|
|
1379
1448
|
}
|
|
1380
1449
|
}
|
|
1450
|
+
function targetCardinality(node, port) {
|
|
1451
|
+
if (node.kind !== "task") return "none";
|
|
1452
|
+
return portCardinality(node.task, port);
|
|
1453
|
+
}
|
|
1454
|
+
function slotRefusal(node, port, wired) {
|
|
1455
|
+
const cardinality = targetCardinality(node, port);
|
|
1456
|
+
let limit = cardinalityLimit(cardinality);
|
|
1457
|
+
if (cardinality === "many" && node.kind === "task" && node.model) {
|
|
1458
|
+
const cap = tryGetModel(node.model)?.capabilities?.maxInputImages;
|
|
1459
|
+
if (typeof cap === "number" && cap > 0) limit = Math.min(limit, cap);
|
|
1460
|
+
}
|
|
1461
|
+
if (wired < limit) return null;
|
|
1462
|
+
if (cardinality === "one") return `node "${node.id}" accepts one ${port} wire, got more`;
|
|
1463
|
+
if (cardinality === "two") return `node "${node.id}" takes two ${port} wires (start frame, end frame), got more`;
|
|
1464
|
+
return `node "${node.id}" (${node.kind === "task" ? node.model : node.kind}) takes at most ${limit} ${port} wires`;
|
|
1465
|
+
}
|
|
1381
1466
|
function wireLegality(source, target, port) {
|
|
1382
1467
|
if (source.id === target.id) return { ok: false, code: "cycle", message: `node "${source.id}" cannot wire into itself` };
|
|
1383
1468
|
const produced = sourcePortOf(source);
|
|
@@ -1444,7 +1529,7 @@ function validateDocument(doc) {
|
|
|
1444
1529
|
inputNames.add(node.name);
|
|
1445
1530
|
}
|
|
1446
1531
|
const edgeKeys = /* @__PURE__ */ new Set();
|
|
1447
|
-
const slots = /* @__PURE__ */ new
|
|
1532
|
+
const slots = /* @__PURE__ */ new Map();
|
|
1448
1533
|
for (const e of doc.edges) {
|
|
1449
1534
|
const key = edgeKey(e);
|
|
1450
1535
|
if (edgeKeys.has(key)) return { ok: false, code: "conflict", message: `duplicate edge ${key}` };
|
|
@@ -1456,14 +1541,24 @@ function validateDocument(doc) {
|
|
|
1456
1541
|
const legal = wireLegality(source, target, e.port);
|
|
1457
1542
|
if (!legal.ok) return legal;
|
|
1458
1543
|
const slot = slotKey(e.target, e.port);
|
|
1459
|
-
|
|
1544
|
+
const wired = slots.get(slot) ?? 0;
|
|
1545
|
+
const refusal = slotRefusal(target, e.port, wired);
|
|
1546
|
+
if (refusal) return { ok: false, code: "cardinality", message: refusal };
|
|
1547
|
+
slots.set(slot, wired + 1);
|
|
1548
|
+
}
|
|
1549
|
+
for (const [slot, wired] of slots) {
|
|
1550
|
+
if (wired < 2) continue;
|
|
1551
|
+
const [targetId, port] = JSON.parse(slot);
|
|
1552
|
+
const target = byId.get(targetId);
|
|
1553
|
+
if (!target || target.kind !== "task" || port !== "image" || portCardinality(target.task, port) !== "two" || !target.model) continue;
|
|
1554
|
+
const model = tryGetModel(target.model);
|
|
1555
|
+
if (model && !model.capabilities?.acceptsEndFrame) {
|
|
1460
1556
|
return {
|
|
1461
1557
|
ok: false,
|
|
1462
|
-
code: "
|
|
1463
|
-
message: `
|
|
1558
|
+
code: "invalid_port",
|
|
1559
|
+
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.`
|
|
1464
1560
|
};
|
|
1465
1561
|
}
|
|
1466
|
-
slots.add(slot);
|
|
1467
1562
|
}
|
|
1468
1563
|
if (documentTopologicalOrder(doc) === null) {
|
|
1469
1564
|
return { ok: false, code: "cycle", message: `document "${doc.id}" has a cycle in its wires` };
|
|
@@ -1510,6 +1605,12 @@ var ImageSourcePropsSchema = z4.object({
|
|
|
1510
1605
|
label: LabelSchema.nullable(),
|
|
1511
1606
|
collapsed: CollapsedSchema.nullable()
|
|
1512
1607
|
}).partial().strict();
|
|
1608
|
+
var VideoSourcePropsSchema = z4.object({
|
|
1609
|
+
videoUrl: UrlSchema,
|
|
1610
|
+
assetId: AssetIdSchema.nullable(),
|
|
1611
|
+
label: LabelSchema.nullable(),
|
|
1612
|
+
collapsed: CollapsedSchema.nullable()
|
|
1613
|
+
}).partial().strict();
|
|
1513
1614
|
var InputNodePropsSchema = z4.object({
|
|
1514
1615
|
name: InputNameSchema,
|
|
1515
1616
|
type: InputTypeSchema,
|
|
@@ -1547,6 +1648,7 @@ var SetNodePropsOpSchema = z4.discriminatedUnion("kind", [
|
|
|
1547
1648
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("task"), id: IdSchema, props: TaskNodePropsSchema }),
|
|
1548
1649
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("prompt-source"), id: IdSchema, props: PromptSourcePropsSchema }),
|
|
1549
1650
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("image-source"), id: IdSchema, props: ImageSourcePropsSchema }),
|
|
1651
|
+
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("video-source"), id: IdSchema, props: VideoSourcePropsSchema }),
|
|
1550
1652
|
z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("input"), id: IdSchema, props: InputNodePropsSchema })
|
|
1551
1653
|
]);
|
|
1552
1654
|
var SetNodeSettingOpSchema = z4.object({
|
|
@@ -1578,8 +1680,8 @@ var DisconnectOpSchema = z4.object({
|
|
|
1578
1680
|
});
|
|
1579
1681
|
var ExposeAsInputOpSchema = z4.object({
|
|
1580
1682
|
type: z4.literal("exposeAsInput"),
|
|
1581
|
-
/** A prompt-source or
|
|
1582
|
-
* (same id, same produced port type), so its wires survive. */
|
|
1683
|
+
/** A prompt-source, image-source or video-source node; it becomes an input
|
|
1684
|
+
* node IN PLACE (same id, same produced port type), so its wires survive. */
|
|
1583
1685
|
id: IdSchema,
|
|
1584
1686
|
name: InputNameSchema,
|
|
1585
1687
|
required: z4.boolean().optional(),
|
|
@@ -1592,8 +1694,9 @@ var ExposeAsInputOpSchema = z4.object({
|
|
|
1592
1694
|
});
|
|
1593
1695
|
var FreezeInputOpSchema = z4.object({
|
|
1594
1696
|
type: z4.literal("freezeInput"),
|
|
1595
|
-
/** A text or
|
|
1596
|
-
* PLACE carrying `value` (a chosen run's
|
|
1697
|
+
/** A text, image or video input node; it becomes a prompt-source /
|
|
1698
|
+
* image-source / video-source IN PLACE carrying `value` (a chosen run's
|
|
1699
|
+
* resolved value, or any literal). */
|
|
1597
1700
|
id: IdSchema,
|
|
1598
1701
|
value: PromptTextSchema,
|
|
1599
1702
|
assetId: AssetIdSchema.optional(),
|
|
@@ -1802,6 +1905,8 @@ var TASK_TO_KIND = {
|
|
|
1802
1905
|
// vision (text/json output, no binary asset)
|
|
1803
1906
|
"image-to-text": "analyze_image",
|
|
1804
1907
|
"image-to-json": "analyze_image",
|
|
1908
|
+
// camera-motion record (text + json output, no binary asset), Studio's own worker
|
|
1909
|
+
"video-to-text": "analyze_video",
|
|
1805
1910
|
// utility (new Image asset off the source)
|
|
1806
1911
|
upscale: "transform_image",
|
|
1807
1912
|
"background-remove": "transform_image",
|
|
@@ -1825,7 +1930,7 @@ function taskExecution(task) {
|
|
|
1825
1930
|
function paramTypeForInput(type) {
|
|
1826
1931
|
return type === "json" ? "json" : "string";
|
|
1827
1932
|
}
|
|
1828
|
-
function
|
|
1933
|
+
function isUsableSourceUrl(url) {
|
|
1829
1934
|
return url.length > 0 && !url.startsWith("data:");
|
|
1830
1935
|
}
|
|
1831
1936
|
function documentToDefinition(doc, opts = {}) {
|
|
@@ -1883,22 +1988,50 @@ function documentToDefinition(doc, opts = {}) {
|
|
|
1883
1988
|
issues.push({ code: "prompt_required", nodeId: node.id, message: `Node "${node.id}" (${task}) needs a prompt.` });
|
|
1884
1989
|
}
|
|
1885
1990
|
}
|
|
1991
|
+
const byPort = /* @__PURE__ */ new Map();
|
|
1886
1992
|
for (const e of wires) {
|
|
1887
1993
|
if (e.port === "text") continue;
|
|
1888
|
-
const
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1994
|
+
const list = byPort.get(e.port);
|
|
1995
|
+
if (list) list.push(e);
|
|
1996
|
+
else byPort.set(e.port, [e]);
|
|
1997
|
+
}
|
|
1998
|
+
for (const [port, portWires] of byPort) {
|
|
1999
|
+
const b = portBinding(task, port);
|
|
2000
|
+
const cardinality = portCardinality(task, port);
|
|
2001
|
+
const items = [];
|
|
2002
|
+
let uploading = false;
|
|
2003
|
+
for (const [ordinal, e] of portWires.entries()) {
|
|
2004
|
+
const source = byId.get(e.source);
|
|
2005
|
+
const single = cardinality !== "many" || portWires.length === 1;
|
|
2006
|
+
const key = orderedPortKey(task, port, ordinal) ?? b.key;
|
|
2007
|
+
const wrap = single && b.wrap === "array";
|
|
2008
|
+
let item = null;
|
|
2009
|
+
if (source.kind === "image-source") {
|
|
2010
|
+
const url = source.imageUrl.trim();
|
|
2011
|
+
if (!isUsableSourceUrl(url)) {
|
|
2012
|
+
issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
|
|
2013
|
+
uploading = true;
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
item = { kind: "literal", value: wrap ? [url] : url };
|
|
2017
|
+
} else if (source.kind === "video-source") {
|
|
2018
|
+
const clip = source.videoUrl.trim();
|
|
2019
|
+
if (!isUsableSourceUrl(clip)) {
|
|
2020
|
+
issues.push({ code: "source_uploading", nodeId: node.id, message: `Video source for "${node.id}" is still uploading; wait for it to finish.` });
|
|
2021
|
+
uploading = true;
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
item = { kind: "literal", value: wrap ? [clip] : clip };
|
|
2025
|
+
} else if (source.kind === "input") {
|
|
2026
|
+
item = wrap && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
|
|
2027
|
+
} else if (source.kind === "task") {
|
|
2028
|
+
item = wrap ? { kind: "ref", node: source.id, path: b.path, wrap: "array" } : { kind: "ref", node: source.id, path: b.path };
|
|
1895
2029
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
} else if (source.kind === "task") {
|
|
1900
|
-
inputs[b.key] = b.wrap === "array" ? { kind: "ref", node: source.id, path: b.path, wrap: "array" } : { kind: "ref", node: source.id, path: b.path };
|
|
2030
|
+
if (!item) continue;
|
|
2031
|
+
if (single) inputs[key] = item;
|
|
2032
|
+
else items.push(item);
|
|
1901
2033
|
}
|
|
2034
|
+
if (!uploading && items.length > 0) inputs[b.key] = { kind: "list", items };
|
|
1902
2035
|
}
|
|
1903
2036
|
for (const [key, binding] of Object.entries(node.bakedInputs)) if (!(key in inputs)) inputs[key] = binding;
|
|
1904
2037
|
if (Object.keys(inputs).length === 0 && !promptMissing) {
|
|
@@ -2136,8 +2269,24 @@ function runFailureSummary(res) {
|
|
|
2136
2269
|
var DEFAULT_SWAP_EDIT_MODEL = "fal/nano-banana-2-edit";
|
|
2137
2270
|
var DEFAULT_SWAP_VIDEO_MODEL = "fal/seedance-pro-image-to-video";
|
|
2138
2271
|
var KEYFRAME_SWAP_VIDEO_MODEL = "fal/kling-v2-5-image-to-video";
|
|
2139
|
-
var
|
|
2140
|
-
|
|
2272
|
+
var SWAP_SCOPES = ["identity", "full_body"];
|
|
2273
|
+
function composeSwapPrompt(options = {}) {
|
|
2274
|
+
const scope = options.scope ?? "identity";
|
|
2275
|
+
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";
|
|
2276
|
+
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.";
|
|
2277
|
+
const sentences = [
|
|
2278
|
+
`Replace the person in the first image with the person in the second image: ${taken}.`,
|
|
2279
|
+
kept,
|
|
2280
|
+
"Match the first image's lighting, focus and colour, and keep its framing and background."
|
|
2281
|
+
];
|
|
2282
|
+
for (const raw of options.preserve ?? []) {
|
|
2283
|
+
const constraint = raw.trim().replace(/[.\s]+$/, "");
|
|
2284
|
+
if (constraint) sentences.push(`${constraint.charAt(0).toUpperCase()}${constraint.slice(1)}.`);
|
|
2285
|
+
}
|
|
2286
|
+
return sentences.join(" ");
|
|
2287
|
+
}
|
|
2288
|
+
var DEFAULT_SWAP_PROMPT = composeSwapPrompt();
|
|
2289
|
+
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.";
|
|
2141
2290
|
var SWAP_EDIT_NODE_ID = "edit";
|
|
2142
2291
|
var SWAP_VIDEO_NODE_ID = "video";
|
|
2143
2292
|
function defaultVideoModelFor(input) {
|
|
@@ -2157,7 +2306,7 @@ function buildCharacterSwapDefinition(input) {
|
|
|
2157
2306
|
task: "image-edit",
|
|
2158
2307
|
model: input.editModel ?? DEFAULT_SWAP_EDIT_MODEL,
|
|
2159
2308
|
inputs: {
|
|
2160
|
-
prompt: { kind: "literal", value: input.swapPrompt ??
|
|
2309
|
+
prompt: { kind: "literal", value: input.swapPrompt ?? composeSwapPrompt({ scope: input.scope, preserve: input.preserve }) },
|
|
2161
2310
|
inputImages: { kind: "literal", value: [input.sceneFrameUrl, input.characterImageUrl] },
|
|
2162
2311
|
aspectRatio: { kind: "literal", value: input.aspectRatio ?? "16:9" }
|
|
2163
2312
|
}
|
|
@@ -2266,6 +2415,9 @@ function summarizeNode(node, wires) {
|
|
|
2266
2415
|
case "image-source":
|
|
2267
2416
|
base.imageUrl = clipText(node.imageUrl);
|
|
2268
2417
|
break;
|
|
2418
|
+
case "video-source":
|
|
2419
|
+
base.videoUrl = clipText(node.videoUrl);
|
|
2420
|
+
break;
|
|
2269
2421
|
case "input":
|
|
2270
2422
|
base.name = node.name;
|
|
2271
2423
|
base.type = node.type;
|
|
@@ -2887,7 +3039,7 @@ var studioUploadBrandReference = defineTool({
|
|
|
2887
3039
|
}
|
|
2888
3040
|
});
|
|
2889
3041
|
var workflowDefinitionSchema = WorkflowDefinitionSchema.describe(
|
|
2890
|
-
'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"}
|
|
3042
|
+
'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).'
|
|
2891
3043
|
);
|
|
2892
3044
|
var studioRunWorkflow = defineTool({
|
|
2893
3045
|
name: "studio_run_workflow",
|
|
@@ -2985,14 +3137,16 @@ var studioGetRunOutput = defineTool({
|
|
|
2985
3137
|
});
|
|
2986
3138
|
var studioReplaceCharacterInVideo = defineTool({
|
|
2987
3139
|
name: "studio_replace_character_in_video",
|
|
2988
|
-
description:
|
|
3140
|
+
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.',
|
|
2989
3141
|
inputSchema: {
|
|
2990
3142
|
projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
|
|
2991
3143
|
sceneFrameUrl: httpUrl.describe("Public or signed URL of ONE frame of the target shot (png/jpg)."),
|
|
2992
3144
|
characterSlug: slug.optional().describe("Minted character to insert; its full-body reference (fallback three-quarter, face) is resolved automatically."),
|
|
2993
3145
|
characterReferenceUrl: httpUrl.optional().describe("Explicit character reference image URL (use instead of characterSlug)."),
|
|
2994
3146
|
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."),
|
|
2995
|
-
|
|
3147
|
+
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."),
|
|
3148
|
+
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.'),
|
|
3149
|
+
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."),
|
|
2996
3150
|
motionPrompt: z8.string().optional().describe("Override the motion description for the video node."),
|
|
2997
3151
|
videoModel: z8.string().optional().describe(`Image-to-video model id. Default ${DEFAULT_SWAP_VIDEO_MODEL}, or ${KEYFRAME_SWAP_VIDEO_MODEL} when endFrameUrl is given.`),
|
|
2998
3152
|
editModel: z8.string().default(DEFAULT_SWAP_EDIT_MODEL).describe("Image-edit model id for the still swap."),
|
|
@@ -3023,6 +3177,8 @@ var studioReplaceCharacterInVideo = defineTool({
|
|
|
3023
3177
|
characterImageUrl,
|
|
3024
3178
|
...args.endFrameUrl ? { endFrameUrl: args.endFrameUrl } : {},
|
|
3025
3179
|
...args.swapPrompt ? { swapPrompt: args.swapPrompt } : {},
|
|
3180
|
+
...args.scope ? { scope: args.scope } : {},
|
|
3181
|
+
...args.preserve ? { preserve: args.preserve } : {},
|
|
3026
3182
|
...args.motionPrompt ? { motionPrompt: args.motionPrompt } : {},
|
|
3027
3183
|
editModel: args.editModel,
|
|
3028
3184
|
videoModel,
|
|
@@ -3185,7 +3341,7 @@ function registerTools(server, ctx) {
|
|
|
3185
3341
|
|
|
3186
3342
|
// src/server.ts
|
|
3187
3343
|
var SERVER_NAME = "lumitra-studio";
|
|
3188
|
-
var SERVER_VERSION = "0.
|
|
3344
|
+
var SERVER_VERSION = "0.2.0";
|
|
3189
3345
|
function createStudioMcpServer(opts = {}) {
|
|
3190
3346
|
const config = opts.config ?? resolveConfig(opts.env ?? process.env);
|
|
3191
3347
|
const client = new StudioClient(config, {
|
|
@@ -3357,6 +3513,7 @@ export {
|
|
|
3357
3513
|
SERVER_NAME,
|
|
3358
3514
|
SERVER_VERSION,
|
|
3359
3515
|
SUMMARY_TEXT_CHARS,
|
|
3516
|
+
SWAP_SCOPES,
|
|
3360
3517
|
StudioApiError,
|
|
3361
3518
|
StudioClient,
|
|
3362
3519
|
TOOLS,
|
|
@@ -3368,6 +3525,7 @@ export {
|
|
|
3368
3525
|
buildCharacterSwapDefinition,
|
|
3369
3526
|
categoryForNode,
|
|
3370
3527
|
characterSheetDefinition,
|
|
3528
|
+
composeSwapPrompt,
|
|
3371
3529
|
createHttpHandler,
|
|
3372
3530
|
createStudioMcpServer,
|
|
3373
3531
|
invokeTool,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marlinjai/studio-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Model Context Protocol (MCP) server over the Lumitra Studio HTTP API: generate images, mint characters, run workflows, swap characters into video.",
|
|
6
6
|
"license": "MIT",
|