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