@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/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 BindingSchema = z2.discriminatedUnion("kind", [
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 Set();
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
- if (slots.has(slot)) {
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: "cardinality",
1463
- message: `node "${e.target}" accepts one ${e.port} wire, got more`
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 image-source node; it becomes an input node IN PLACE
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 image input node; it becomes a prompt-source / image-source IN
1596
- * PLACE carrying `value` (a chosen run's resolved value, or any literal). */
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 isUsableImageUrl(url) {
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 b = portBinding(task, e.port);
1889
- const source = byId.get(e.source);
1890
- if (source.kind === "image-source") {
1891
- const url = source.imageUrl.trim();
1892
- if (!isUsableImageUrl(url)) {
1893
- issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
1894
- continue;
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
- inputs[b.key] = { kind: "literal", value: b.wrap === "array" ? [url] : url };
1897
- } else if (source.kind === "input") {
1898
- inputs[b.key] = b.wrap === "array" && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
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 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.";
2140
- 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.";
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 ?? DEFAULT_SWAP_PROMPT },
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"} 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).'
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: "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.",
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
- swapPrompt: z8.string().optional().describe("Override the still-swap edit prompt."),
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.1.1";
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.1.1",
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",