@marlinjai/studio-mcp 0.1.0 → 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
@@ -213,8 +213,8 @@ var StudioClient = class {
213
213
  }
214
214
  /** S9: a rendered diagram of a saved workflow's document, so an agent can
215
215
  * see the graph it just built. */
216
- getWorkflowScreenshot(savedId2) {
217
- return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}/screenshot`);
216
+ getWorkflowScreenshot(savedId2, opts = {}) {
217
+ return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}/screenshot${projectQuery(opts.projectId)}`);
218
218
  }
219
219
  // ── Recipes (S10): published workflow + closed post-run effect list ──
220
220
  publishWorkflow(savedId2, body) {
@@ -239,9 +239,10 @@ var StudioClient = class {
239
239
  describeNodeTypes() {
240
240
  return this.json("GET", "/api/v1/node-types");
241
241
  }
242
- /** `savedId` carries a `saved:` prefix; the colon is URL-encoded. */
243
- getWorkflowDocument(savedId2) {
244
- return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}`);
242
+ /** `savedId` carries a `saved:` prefix; the colon is URL-encoded. `projectId`
243
+ * names the project the workflow lives in; absent, the Studio's default. */
244
+ getWorkflowDocument(savedId2, opts = {}) {
245
+ return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}${projectQuery(opts.projectId)}`);
245
246
  }
246
247
  /** Create a document by applying `ops` to an empty one (the same code path as apply). */
247
248
  createWorkflow(body) {
@@ -412,6 +413,9 @@ function mimeForFilename(name) {
412
413
  return "application/octet-stream";
413
414
  }
414
415
  }
416
+ function projectQuery(projectId) {
417
+ return projectId === void 0 ? "" : `?projectId=${encodeURIComponent(projectId)}`;
418
+ }
415
419
 
416
420
  // src/tools.ts
417
421
  import { z as z8 } from "zod";
@@ -444,6 +448,10 @@ var MODEL_TASKS = [
444
448
  "speech-to-speech",
445
449
  "image-to-text",
446
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",
447
455
  // A human decision gate over candidate images (P3 J1). Still a model task,
448
456
  // not a new node kind: WorkflowNode already requires task + model, and the
449
457
  // ranking call underneath is the same image-to-json vision model
@@ -488,6 +496,8 @@ var TASK_PORTS = {
488
496
  // VISION + LANGUAGE.
489
497
  "image-to-text": { inputs: ["image", "text"], output: "text" },
490
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" },
491
501
  // Judge (P3 J1): one candidate wire in (the several results of ONE
492
502
  // upstream node-run, not fan-in), the approved candidate out. Both sides
493
503
  // are typed 'image' so an approved candidate can feed straight into any
@@ -551,11 +561,21 @@ var RefBindingSchema = z2.object({
551
561
  // the "no array-of-refs" gap without widening the binding grammar itself.
552
562
  wrap: z2.literal("array").optional()
553
563
  });
554
- var BindingSchema = z2.discriminatedUnion("kind", [
564
+ var ListItemBindingSchema = z2.discriminatedUnion("kind", [
555
565
  LiteralBindingSchema,
556
566
  ParamBindingSchema,
557
567
  RefBindingSchema
558
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
+ ]);
559
579
  var WorkflowTaskSchema = z2.enum(MODEL_TASKS);
560
580
  var RetryPolicySchema = z2.object({
561
581
  maxAttempts: z2.number().int().positive(),
@@ -634,6 +654,7 @@ var AssetIdSchema = z3.string().max(128);
634
654
  var PortTypeSchema = z3.enum(PORT_TYPES);
635
655
  var InputTypeSchema = z3.enum(INPUT_PORT_TYPES);
636
656
  var PositionSchema = z3.object({ x: z3.number().finite(), y: z3.number().finite() });
657
+ var CollapsedSchema = z3.boolean();
637
658
  var TaskNodeSchema = z3.object({
638
659
  id: IdSchema,
639
660
  kind: z3.literal("task"),
@@ -654,13 +675,15 @@ var TaskNodeSchema = z3.object({
654
675
  bakedInputs: z3.record(KeySchema, LiteralBindingSchema).default({}),
655
676
  /** The generation id that feeds downstream. Absent = latest (D4). */
656
677
  activeGeneration: IdSchema.optional(),
657
- label: LabelSchema.optional()
678
+ label: LabelSchema.optional(),
679
+ collapsed: CollapsedSchema.optional()
658
680
  });
659
681
  var PromptSourceNodeSchema = z3.object({
660
682
  id: IdSchema,
661
683
  kind: z3.literal("prompt-source"),
662
684
  text: PromptTextSchema.default(""),
663
- label: LabelSchema.optional()
685
+ label: LabelSchema.optional(),
686
+ collapsed: CollapsedSchema.optional()
664
687
  });
665
688
  var ImageSourceNodeSchema = z3.object({
666
689
  id: IdSchema,
@@ -671,7 +694,18 @@ var ImageSourceNodeSchema = z3.object({
671
694
  /** The Storage Brain asset id, when known (E18: every image input resolves to
672
695
  * an asset id). */
673
696
  assetId: AssetIdSchema.optional(),
674
- label: LabelSchema.optional()
697
+ label: LabelSchema.optional(),
698
+ collapsed: CollapsedSchema.optional()
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()
675
709
  });
676
710
  var InputNodeSchema = z3.object({
677
711
  id: IdSchema,
@@ -686,12 +720,14 @@ var InputNodeSchema = z3.object({
686
720
  /** Used when a run omits the input. Never null: absent means no default. */
687
721
  default: JsonNonNullValueSchema.optional(),
688
722
  label: LabelSchema.optional(),
689
- description: DescriptionSchema.optional()
723
+ description: DescriptionSchema.optional(),
724
+ collapsed: CollapsedSchema.optional()
690
725
  });
691
726
  var DocNodeSchema = z3.discriminatedUnion("kind", [
692
727
  TaskNodeSchema,
693
728
  PromptSourceNodeSchema,
694
729
  ImageSourceNodeSchema,
730
+ VideoSourceNodeSchema,
695
731
  InputNodeSchema
696
732
  ]);
697
733
  var DocEdgeSchema = z3.object({
@@ -704,6 +740,18 @@ var DocGroupSchema = z3.object({
704
740
  label: LabelSchema.optional(),
705
741
  nodeIds: z3.array(IdSchema).min(1)
706
742
  });
743
+ var EDGE_ROUTINGS = ["curved", "orthogonal"];
744
+ var EdgeRoutingSchema = z3.enum(EDGE_ROUTINGS);
745
+ var WireRouteSchema = z3.object({
746
+ from: PositionSchema,
747
+ to: PositionSchema,
748
+ points: z3.array(PositionSchema).max(64)
749
+ });
750
+ var DocumentViewSchema = z3.object({
751
+ edgeRouting: EdgeRoutingSchema.optional(),
752
+ /** Routed wires from the last Arrange, keyed by `edgeKey`. */
753
+ wires: z3.record(z3.string().max(512), WireRouteSchema).optional()
754
+ });
707
755
  var WorkflowDocumentSchema = z3.object({
708
756
  schema: z3.number().int().positive(),
709
757
  id: IdSchema,
@@ -711,7 +759,10 @@ var WorkflowDocumentSchema = z3.object({
711
759
  nodes: z3.array(DocNodeSchema).max(2e3),
712
760
  edges: z3.array(DocEdgeSchema).max(8e3),
713
761
  layout: z3.record(IdSchema, PositionSchema).default({}),
714
- groups: z3.array(DocGroupSchema).max(500).default([])
762
+ groups: z3.array(DocGroupSchema).max(500).default([]),
763
+ /** Display preferences; absent on every document written before they
764
+ * existed (additive, so `DOCUMENT_SCHEMA_VERSION` is unchanged). */
765
+ view: DocumentViewSchema.optional()
715
766
  });
716
767
  function edgeKey(e) {
717
768
  return JSON.stringify([e.source, e.target, e.port]);
@@ -963,7 +1014,7 @@ var MODEL_CATALOG = [
963
1014
  label: "Kling 2.5 Turbo Pro (image-to-video)",
964
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.",
965
1016
  cost: { usd: 0.35 },
966
- capabilities: { needsImageInput: true, maxInputImages: 1 },
1017
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true },
967
1018
  isDefault: true
968
1019
  },
969
1020
  {
@@ -984,7 +1035,7 @@ var MODEL_CATALOG = [
984
1035
  label: "Seedance 2.5 (image-to-video)",
985
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.",
986
1037
  cost: { usd: 1.89 },
987
- capabilities: { needsImageInput: true, maxInputImages: 1 }
1038
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
988
1039
  },
989
1040
  {
990
1041
  id: "fal/seedance-2-0-image-to-video",
@@ -994,7 +1045,7 @@ var MODEL_CATALOG = [
994
1045
  label: "Seedance 2.0 (image-to-video)",
995
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.",
996
1047
  cost: { usd: 1.89 },
997
- capabilities: { needsImageInput: true, maxInputImages: 1 }
1048
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
998
1049
  },
999
1050
  // ── FLUX.3 (Black Forest Labs) ───────────────────────────────────────────
1000
1051
  // One model, three tiers of the same three input shapes:
@@ -1029,7 +1080,7 @@ var MODEL_CATALOG = [
1029
1080
  label: "FLUX.3 draft (first + last frame)",
1030
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.",
1031
1082
  cost: { usd: 0.3 },
1032
- capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true }
1083
+ capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true, acceptsEndFrame: true }
1033
1084
  },
1034
1085
  {
1035
1086
  id: "fal/flux-3-keyframes-to-video-draft",
@@ -1059,7 +1110,7 @@ var MODEL_CATALOG = [
1059
1110
  label: "FLUX.3 (first + last frame)",
1060
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.",
1061
1112
  cost: { usd: 0.85 },
1062
- capabilities: { needsImageInput: true, maxInputImages: 2 }
1113
+ capabilities: { needsImageInput: true, maxInputImages: 2, acceptsEndFrame: true }
1063
1114
  },
1064
1115
  {
1065
1116
  id: "fal/flux-3-keyframes-to-video",
@@ -1129,6 +1180,19 @@ var MODEL_CATALOG = [
1129
1180
  capabilities: { needsImageInput: true, maxInputImages: 1 },
1130
1181
  isDefault: true
1131
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
+ },
1132
1196
  {
1133
1197
  id: "fal/moondream-next-vqa",
1134
1198
  provider: "fal",
@@ -1320,6 +1384,9 @@ function portBinding(task, port) {
1320
1384
  if (port === "mesh3d" && (task === "remesh" || task === "texture")) {
1321
1385
  return { key: "parentAssetId", path: "assetId" };
1322
1386
  }
1387
+ if (port === "video" && task === "video-to-text") {
1388
+ return { key: "inputVideoUrl", path: "url" };
1389
+ }
1323
1390
  if (port === "video" && task === "video-to-video") {
1324
1391
  return { key: "inputVideoUrl", path: "url" };
1325
1392
  }
@@ -1350,6 +1417,30 @@ function canConnect(sourceOut, targetTask, targetPort) {
1350
1417
  if (sourceOut !== targetPort) return false;
1351
1418
  return portBinding(targetTask, targetPort) !== null;
1352
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
+ }
1353
1444
  function sourcePortOf(node) {
1354
1445
  switch (node.kind) {
1355
1446
  case "task":
@@ -1358,10 +1449,28 @@ function sourcePortOf(node) {
1358
1449
  return "text";
1359
1450
  case "image-source":
1360
1451
  return "image";
1452
+ case "video-source":
1453
+ return "video";
1361
1454
  case "input":
1362
1455
  return node.type;
1363
1456
  }
1364
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
+ }
1365
1474
  function wireLegality(source, target, port) {
1366
1475
  if (source.id === target.id) return { ok: false, code: "cycle", message: `node "${source.id}" cannot wire into itself` };
1367
1476
  const produced = sourcePortOf(source);
@@ -1428,7 +1537,7 @@ function validateDocument(doc) {
1428
1537
  inputNames.add(node.name);
1429
1538
  }
1430
1539
  const edgeKeys = /* @__PURE__ */ new Set();
1431
- const slots = /* @__PURE__ */ new Set();
1540
+ const slots = /* @__PURE__ */ new Map();
1432
1541
  for (const e of doc.edges) {
1433
1542
  const key = edgeKey(e);
1434
1543
  if (edgeKeys.has(key)) return { ok: false, code: "conflict", message: `duplicate edge ${key}` };
@@ -1440,14 +1549,24 @@ function validateDocument(doc) {
1440
1549
  const legal = wireLegality(source, target, e.port);
1441
1550
  if (!legal.ok) return legal;
1442
1551
  const slot = slotKey(e.target, e.port);
1443
- 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) {
1444
1564
  return {
1445
1565
  ok: false,
1446
- code: "cardinality",
1447
- 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.`
1448
1568
  };
1449
1569
  }
1450
- slots.add(slot);
1451
1570
  }
1452
1571
  if (documentTopologicalOrder(doc) === null) {
1453
1572
  return { ok: false, code: "cycle", message: `document "${doc.id}" has a cycle in its wires` };
@@ -1480,16 +1599,25 @@ var TaskNodePropsSchema = z4.object({
1480
1599
  task: z4.enum(MODEL_TASKS),
1481
1600
  model: ModelIdSchema,
1482
1601
  prompt: PromptTextSchema,
1483
- label: LabelSchema.nullable()
1602
+ label: LabelSchema.nullable(),
1603
+ collapsed: CollapsedSchema.nullable()
1484
1604
  }).partial().strict();
1485
1605
  var PromptSourcePropsSchema = z4.object({
1486
1606
  text: PromptTextSchema,
1487
- label: LabelSchema.nullable()
1607
+ label: LabelSchema.nullable(),
1608
+ collapsed: CollapsedSchema.nullable()
1488
1609
  }).partial().strict();
1489
1610
  var ImageSourcePropsSchema = z4.object({
1490
1611
  imageUrl: UrlSchema,
1491
1612
  assetId: AssetIdSchema.nullable(),
1492
- label: LabelSchema.nullable()
1613
+ label: LabelSchema.nullable(),
1614
+ collapsed: CollapsedSchema.nullable()
1615
+ }).partial().strict();
1616
+ var VideoSourcePropsSchema = z4.object({
1617
+ videoUrl: UrlSchema,
1618
+ assetId: AssetIdSchema.nullable(),
1619
+ label: LabelSchema.nullable(),
1620
+ collapsed: CollapsedSchema.nullable()
1493
1621
  }).partial().strict();
1494
1622
  var InputNodePropsSchema = z4.object({
1495
1623
  name: InputNameSchema,
@@ -1498,14 +1626,19 @@ var InputNodePropsSchema = z4.object({
1498
1626
  cardinality: z4.enum(["one", "many"]).nullable(),
1499
1627
  default: JsonNonNullValueSchema.nullable(),
1500
1628
  label: LabelSchema.nullable(),
1501
- description: DescriptionSchema.nullable()
1629
+ description: DescriptionSchema.nullable(),
1630
+ collapsed: CollapsedSchema.nullable()
1502
1631
  }).partial().strict();
1503
1632
  var GroupPropsSchema = z4.object({
1504
1633
  label: LabelSchema.nullable(),
1505
1634
  nodeIds: z4.array(IdSchema).min(1)
1506
1635
  }).partial().strict();
1507
1636
  var DocumentPropsSchema = z4.object({
1508
- label: LabelSchema
1637
+ label: LabelSchema,
1638
+ /** Display preferences (`view.edgeRouting`); null clears the whole
1639
+ * object. The object is replaced, not merged: pass every field you want
1640
+ * kept. */
1641
+ view: DocumentViewSchema.strict().nullable()
1509
1642
  }).partial().strict();
1510
1643
  var AddNodeOpSchema = z4.object({
1511
1644
  type: z4.literal("addNode"),
@@ -1523,6 +1656,7 @@ var SetNodePropsOpSchema = z4.discriminatedUnion("kind", [
1523
1656
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("task"), id: IdSchema, props: TaskNodePropsSchema }),
1524
1657
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("prompt-source"), id: IdSchema, props: PromptSourcePropsSchema }),
1525
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 }),
1526
1660
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("input"), id: IdSchema, props: InputNodePropsSchema })
1527
1661
  ]);
1528
1662
  var SetNodeSettingOpSchema = z4.object({
@@ -1554,8 +1688,8 @@ var DisconnectOpSchema = z4.object({
1554
1688
  });
1555
1689
  var ExposeAsInputOpSchema = z4.object({
1556
1690
  type: z4.literal("exposeAsInput"),
1557
- /** A prompt-source or image-source node; it becomes an input node IN PLACE
1558
- * (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. */
1559
1693
  id: IdSchema,
1560
1694
  name: InputNameSchema,
1561
1695
  required: z4.boolean().optional(),
@@ -1568,8 +1702,9 @@ var ExposeAsInputOpSchema = z4.object({
1568
1702
  });
1569
1703
  var FreezeInputOpSchema = z4.object({
1570
1704
  type: z4.literal("freezeInput"),
1571
- /** A text or image input node; it becomes a prompt-source / image-source IN
1572
- * 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). */
1573
1708
  id: IdSchema,
1574
1709
  value: PromptTextSchema,
1575
1710
  assetId: AssetIdSchema.optional(),
@@ -1778,6 +1913,8 @@ var TASK_TO_KIND = {
1778
1913
  // vision (text/json output, no binary asset)
1779
1914
  "image-to-text": "analyze_image",
1780
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",
1781
1918
  // utility (new Image asset off the source)
1782
1919
  upscale: "transform_image",
1783
1920
  "background-remove": "transform_image",
@@ -1801,7 +1938,7 @@ function taskExecution(task) {
1801
1938
  function paramTypeForInput(type) {
1802
1939
  return type === "json" ? "json" : "string";
1803
1940
  }
1804
- function isUsableImageUrl(url) {
1941
+ function isUsableSourceUrl(url) {
1805
1942
  return url.length > 0 && !url.startsWith("data:");
1806
1943
  }
1807
1944
  function documentToDefinition(doc, opts = {}) {
@@ -1859,22 +1996,50 @@ function documentToDefinition(doc, opts = {}) {
1859
1996
  issues.push({ code: "prompt_required", nodeId: node.id, message: `Node "${node.id}" (${task}) needs a prompt.` });
1860
1997
  }
1861
1998
  }
1999
+ const byPort = /* @__PURE__ */ new Map();
1862
2000
  for (const e of wires) {
1863
2001
  if (e.port === "text") continue;
1864
- const b = portBinding(task, e.port);
1865
- const source = byId.get(e.source);
1866
- if (source.kind === "image-source") {
1867
- const url = source.imageUrl.trim();
1868
- if (!isUsableImageUrl(url)) {
1869
- issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
1870
- 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 };
1871
2037
  }
1872
- inputs[b.key] = { kind: "literal", value: b.wrap === "array" ? [url] : url };
1873
- } else if (source.kind === "input") {
1874
- inputs[b.key] = b.wrap === "array" && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
1875
- } else if (source.kind === "task") {
1876
- 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);
1877
2041
  }
2042
+ if (!uploading && items.length > 0) inputs[b.key] = { kind: "list", items };
1878
2043
  }
1879
2044
  for (const [key, binding] of Object.entries(node.bakedInputs)) if (!(key in inputs)) inputs[key] = binding;
1880
2045
  if (Object.keys(inputs).length === 0 && !promptMissing) {
@@ -2076,7 +2241,7 @@ async function pollUntil(what, fetchState, isDone, opts = {}) {
2076
2241
  }
2077
2242
  }
2078
2243
  var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "canceled"]);
2079
- var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "partial"]);
2244
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "partial", "cancelled"]);
2080
2245
  function isJobTerminal(res) {
2081
2246
  return TERMINAL_JOB_STATUSES.has(res.job.status);
2082
2247
  }
@@ -2109,8 +2274,24 @@ function runFailureSummary(res) {
2109
2274
  var DEFAULT_SWAP_EDIT_MODEL = "fal/nano-banana-2-edit";
2110
2275
  var DEFAULT_SWAP_VIDEO_MODEL = "fal/seedance-pro-image-to-video";
2111
2276
  var KEYFRAME_SWAP_VIDEO_MODEL = "fal/kling-v2-5-image-to-video";
2112
- 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.";
2113
- 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.";
2114
2295
  var SWAP_EDIT_NODE_ID = "edit";
2115
2296
  var SWAP_VIDEO_NODE_ID = "video";
2116
2297
  function defaultVideoModelFor(input) {
@@ -2130,7 +2311,7 @@ function buildCharacterSwapDefinition(input) {
2130
2311
  task: "image-edit",
2131
2312
  model: input.editModel ?? DEFAULT_SWAP_EDIT_MODEL,
2132
2313
  inputs: {
2133
- prompt: { kind: "literal", value: input.swapPrompt ?? DEFAULT_SWAP_PROMPT },
2314
+ prompt: { kind: "literal", value: input.swapPrompt ?? composeSwapPrompt({ scope: input.scope, preserve: input.preserve }) },
2134
2315
  inputImages: { kind: "literal", value: [input.sceneFrameUrl, input.characterImageUrl] },
2135
2316
  aspectRatio: { kind: "literal", value: input.aspectRatio ?? "16:9" }
2136
2317
  }
@@ -2239,6 +2420,9 @@ function summarizeNode(node, wires) {
2239
2420
  case "image-source":
2240
2421
  base.imageUrl = clipText(node.imageUrl);
2241
2422
  break;
2423
+ case "video-source":
2424
+ base.videoUrl = clipText(node.videoUrl);
2425
+ break;
2242
2426
  case "input":
2243
2427
  base.name = node.name;
2244
2428
  base.type = node.type;
@@ -2409,11 +2593,12 @@ var studioWorkflowGet = defineTool({
2409
2593
  description: "Read a saved workflow. By default returns a summary: { savedId, revision, label, readOnly, nodes: [{ id, kind, label?, task?, model?, prompt? (first 80 chars, plus promptLength), imageUrl?, name?, type?, required?, wires: { in, out } }], edgeCount, groupCount, projection: { ok, issues } } where revision is the baseRevision for studio_workflow_apply and projection says whether the workflow can run and why not. A workflow the canvas cannot represent comes back as { readOnly: true, definitionNodeCount, projection, hint } with no nodes: it is readable and runnable (studio_run_workflow with its savedId) but not patchable. needsLossyAcknowledgement: true means its first patch must carry acknowledgeLossy: true. full: true returns the whole response instead; use it only when you need every field.",
2410
2594
  inputSchema: {
2411
2595
  savedId,
2596
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project."),
2412
2597
  full: z7.boolean().default(false).describe("Return the whole response (the full document, or the stored definition of a read-only workflow) instead of the summary.")
2413
2598
  },
2414
2599
  annotations: { readOnlyHint: true },
2415
2600
  handler: async (args, ctx) => {
2416
- const res = await ctx.client.getWorkflowDocument(args.savedId);
2601
+ const res = await ctx.client.getWorkflowDocument(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2417
2602
  if (args.full) return res;
2418
2603
  if (res.readOnly === true || res.document === null) {
2419
2604
  const definition = isRecord(res.definition) ? res.definition : null;
@@ -2486,6 +2671,7 @@ var studioWorkflowApply = defineTool({
2486
2671
  description: 'Change a saved workflow atomically: the ops are applied all-or-nothing on top of baseRevision (the revision from studio_workflow_get, studio_workflow_create or your last apply). Success returns { ok: true, savedId, revision, appliedCount, inverseCount, affectedNodes (summaries of the nodes the patch touched, removed ones as { id, removed: true }), projection: { ok, issues }, idempotencyKey, replayed }. Every refusal comes back as { ok: false, code, hint } you can act on rather than an error: "stale_revision" (re-read, rebase your ops, apply again on the revision returned), "invalid_patch" (error.opIndex names the offending op), "forbidden" (opIndex plus the missingScope your credential needs), "not_editable" (this workflow is readable and runnable but cannot be patched), "lossy_import" (read notes, then resend with acknowledgeLossy: true and a NEW idempotencyKey), "idempotency_mismatch" (that key belongs to a different patch: use a fresh one), and "network_error" / "server_error" which you retry with the SAME idempotencyKey so the patch can never apply twice.',
2487
2672
  inputSchema: {
2488
2673
  savedId,
2674
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project."),
2489
2675
  baseRevision: PatchSchema.shape.baseRevision.describe("The revision the ops were written against. The Studio refuses a stale one with stale_revision."),
2490
2676
  ops: opsSchema,
2491
2677
  idempotencyKey: PatchSchema.shape.idempotencyKey.describe(
@@ -2500,6 +2686,7 @@ var studioWorkflowApply = defineTool({
2500
2686
  let res;
2501
2687
  try {
2502
2688
  res = await ctx.client.applyWorkflowPatch(args.savedId, {
2689
+ ...args.projectId !== void 0 ? { projectId: args.projectId } : {},
2503
2690
  baseRevision: args.baseRevision,
2504
2691
  ops: args.ops,
2505
2692
  idempotencyKey,
@@ -2534,16 +2721,18 @@ var studioWorkflowValidate = defineTool({
2534
2721
  inputSchema: {
2535
2722
  document: WorkflowDocumentSchema.optional().describe("The document to validate. Either this or savedId."),
2536
2723
  savedId: savedId.optional().describe("Fetch this saved workflow first and validate against its current document. Either this or document, not both."),
2724
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the saved workflow lives in (only with savedId); omit for the default project."),
2537
2725
  ops: PatchSchema.shape.ops.optional().describe("Ops to apply to the document before projecting it (the same vocabulary and limits as studio_workflow_apply)."),
2538
2726
  params: paramsSchema.optional()
2539
2727
  },
2540
2728
  annotations: { readOnlyHint: true },
2541
2729
  handler: async (args, ctx) => {
2542
2730
  if (args.document && args.savedId) throw new ToolInputError("pass either `document` or `savedId`, not both");
2731
+ if (args.projectId !== void 0 && !args.savedId) throw new ToolInputError("`projectId` only applies when fetching by `savedId`");
2543
2732
  let document = args.document;
2544
2733
  if (!document) {
2545
2734
  if (!args.savedId) throw new ToolInputError("provide a `document` or a `savedId` to validate");
2546
- const saved = await ctx.client.getWorkflowDocument(args.savedId);
2735
+ const saved = await ctx.client.getWorkflowDocument(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2547
2736
  if (saved.readOnly === true || saved.document === null) {
2548
2737
  throw new ToolInputError(
2549
2738
  `workflow ${args.savedId} has no document to validate: the canvas cannot represent its stored definition (studio_workflow_get shows readOnly: true and why). It is still runnable with studio_run_workflow.`
@@ -2562,11 +2751,12 @@ var studioWorkflowScreenshot = defineTool({
2562
2751
  name: "studio_workflow_screenshot",
2563
2752
  description: "Render a saved workflow's document as a diagram (labelled boxes and wires, positioned by the document's own layout) so you can see the graph you just built, the Figma get_screenshot equivalent. NOT a pixel-exact preview of the real canvas: a plain diagnostic diagram for checking topology (is everything wired the way I meant, did that addNode land where I expected). Returns { url, width, height }; the url is a signed Storage Brain link, valid for 24h. Free, read-only.",
2564
2753
  inputSchema: {
2565
- savedId
2754
+ savedId,
2755
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project.")
2566
2756
  },
2567
2757
  annotations: { readOnlyHint: true },
2568
2758
  handler: async (args, ctx) => {
2569
- return ctx.client.getWorkflowScreenshot(args.savedId);
2759
+ return ctx.client.getWorkflowScreenshot(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2570
2760
  }
2571
2761
  });
2572
2762
  var WORKFLOW_TOOLS = [
@@ -2599,6 +2789,8 @@ function projectJob(res) {
2599
2789
  function projectNodeRun(nr) {
2600
2790
  return {
2601
2791
  nodeId: nr.nodeId,
2792
+ // The node-run (generation) id: what `upstreamGenerations` pins.
2793
+ id: nr.id ?? null,
2602
2794
  status: nr.status,
2603
2795
  url: nr.resultUrl ?? nr.results?.[0]?.url ?? null,
2604
2796
  kind: nr.results?.[0]?.kind ?? null,
@@ -2852,17 +3044,20 @@ var studioUploadBrandReference = defineTool({
2852
3044
  }
2853
3045
  });
2854
3046
  var workflowDefinitionSchema = WorkflowDefinitionSchema.describe(
2855
- '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).'
2856
3048
  );
2857
3049
  var studioRunWorkflow = defineTool({
2858
3050
  name: "studio_run_workflow",
2859
- description: `Run a generation workflow DAG. Pass an inline definition (nodes of provider calls wired by ref bindings; this is what the hosted Studio accepts) or the savedId of a workflow saved in the Studio (the Studio runs that workflow's current document, exactly what the canvas would run). Params fill {kind:"param"} bindings. Returns the runId plus per-node status, URLs and cost; with wait=true (default) it blocks until the run is terminal (succeeded, failed, partial). Each node becomes a paid job.`,
3051
+ description: `Run a generation workflow DAG. Pass an inline definition (nodes of provider calls wired by ref bindings; this is what the hosted Studio accepts) or the savedId of a workflow saved in the Studio (the Studio runs that workflow's current document, exactly what the canvas would run). Params fill {kind:"param"} bindings. Returns the runId plus per-node status, URLs and cost; with wait=true (default) it blocks until the run is terminal (succeeded, failed, partial, cancelled). Each node becomes a paid job. Use an inline definition ONLY for the first batch of a new deliverable (one workflow, a node per item, a stable label); a follow-up on an existing item belongs in that saved workflow as a new node (studio_workflow_apply: addNode plus connect from the node it refines) run here with savedId and nodeIds, because every listed inline run is saved as its own workflow.`,
2860
3052
  inputSchema: {
2861
3053
  projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
2862
3054
  definition: workflowDefinitionSchema.optional(),
2863
3055
  savedId: z8.string().optional().describe("Id of a saved workflow (from studio_workflow_create, studio_workflow_get or a listed run) to run instead of an inline definition. An unknown id is a 404 naming it; a workflow that is not runnable yet comes back as not_runnable with per-node issues."),
2864
3056
  params: z8.record(z8.string(), z8.unknown()).optional().describe("Values for the definition's params."),
2865
3057
  nodeIds: z8.array(z8.string()).optional().describe("Run only this subset of nodes (requires an inline `definition` or a `savedId`)."),
3058
+ upstreamGenerations: z8.record(z8.string(), z8.string()).optional().describe(
3059
+ 'Pin WHICH generation of an upstream node feeds this subset run: { "<upstream node id>": "<node-run id>" } (the per-node `id` that studio_get_run returns). A verified pin is reused even when the planner has no matching cache key for it (an inline first batch, or a node edited and reverted), so a follow-up node wired from an earlier generation runs against exactly that image. An unknown id is refused naming the node, never silently replaced by the latest.'
3060
+ ),
2866
3061
  runs: z8.number().int().min(1).max(8).optional().describe("Repeat a node subset N times (1 to 8)."),
2867
3062
  visibility: z8.enum(["listed", "unlisted"]).optional().describe("Whether the run shows in the Studio workflow list."),
2868
3063
  budgetUsd: z8.number().nonnegative().optional().describe(
@@ -2886,6 +3081,7 @@ var studioRunWorkflow = defineTool({
2886
3081
  ...args.definition ? { definition: args.definition } : {},
2887
3082
  ...args.params ? { params: args.params } : {},
2888
3083
  ...args.nodeIds && args.nodeIds.length > 0 ? { nodeIds: args.nodeIds } : {},
3084
+ ...args.upstreamGenerations ? { upstreamGenerations: args.upstreamGenerations } : {},
2889
3085
  ...args.runs !== void 0 ? { runs: args.runs } : {},
2890
3086
  ...args.savedId ? { savedId: args.savedId } : {},
2891
3087
  ...args.visibility ? { visibility: args.visibility } : {},
@@ -2899,7 +3095,7 @@ var studioRunWorkflow = defineTool({
2899
3095
  });
2900
3096
  var studioGetRun = defineTool({
2901
3097
  name: "studio_get_run",
2902
- description: "Fetch a workflow run by runId: overall status (pending, running, succeeded, failed, partial, awaiting_decision), total cost, and per-node status, result URL, cost and error message. Set wait=true to block until the run settles. A run at awaiting_decision is parked on a person at a judge node: waiting returns immediately with a `waiting` block naming the node and its candidates, because polling can never clear it; ask the user to choose and submit their answer with studio_decide_judge.",
3098
+ description: "Fetch a workflow run by runId: overall status (pending, running, succeeded, failed, partial, awaiting_decision, cancelled; cancelled means somebody stopped it), total cost, and per-node status, result URL, cost and error message. Set wait=true to block until the run settles. A run at awaiting_decision is parked on a person at a judge node: waiting returns immediately with a `waiting` block naming the node and its candidates, because polling can never clear it; ask the user to choose and submit their answer with studio_decide_judge.",
2903
3099
  inputSchema: {
2904
3100
  runId: z8.string().min(1),
2905
3101
  wait: z8.boolean().default(false),
@@ -2946,14 +3142,16 @@ var studioGetRunOutput = defineTool({
2946
3142
  });
2947
3143
  var studioReplaceCharacterInVideo = defineTool({
2948
3144
  name: "studio_replace_character_in_video",
2949
- 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.',
2950
3146
  inputSchema: {
2951
3147
  projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
2952
3148
  sceneFrameUrl: httpUrl.describe("Public or signed URL of ONE frame of the target shot (png/jpg)."),
2953
3149
  characterSlug: slug.optional().describe("Minted character to insert; its full-body reference (fallback three-quarter, face) is resolved automatically."),
2954
3150
  characterReferenceUrl: httpUrl.optional().describe("Explicit character reference image URL (use instead of characterSlug)."),
2955
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."),
2956
- 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."),
2957
3155
  motionPrompt: z8.string().optional().describe("Override the motion description for the video node."),
2958
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.`),
2959
3157
  editModel: z8.string().default(DEFAULT_SWAP_EDIT_MODEL).describe("Image-edit model id for the still swap."),
@@ -2984,6 +3182,8 @@ var studioReplaceCharacterInVideo = defineTool({
2984
3182
  characterImageUrl,
2985
3183
  ...args.endFrameUrl ? { endFrameUrl: args.endFrameUrl } : {},
2986
3184
  ...args.swapPrompt ? { swapPrompt: args.swapPrompt } : {},
3185
+ ...args.scope ? { scope: args.scope } : {},
3186
+ ...args.preserve ? { preserve: args.preserve } : {},
2987
3187
  ...args.motionPrompt ? { motionPrompt: args.motionPrompt } : {},
2988
3188
  editModel: args.editModel,
2989
3189
  videoModel,
@@ -3146,7 +3346,7 @@ function registerTools(server, ctx) {
3146
3346
 
3147
3347
  // src/server.ts
3148
3348
  var SERVER_NAME = "lumitra-studio";
3149
- var SERVER_VERSION = "0.1.0";
3349
+ var SERVER_VERSION = "0.2.0";
3150
3350
  function createStudioMcpServer(opts = {}) {
3151
3351
  const config = opts.config ?? resolveConfig(opts.env ?? process.env);
3152
3352
  const client = new StudioClient(config, {
@@ -3160,7 +3360,8 @@ function createStudioMcpServer(opts = {}) {
3160
3360
  "Lumitra Studio: brand-consistent image generation, character minting, workflow runs and the still-swap video recipe over the hosted Studio API.",
3161
3361
  "Start with studio_list_projects to get a projectId. Every generation costs real money (roughly $0.04 per image, $0.30 per character mint, $0.70 per video take); check studio_get_spend when in doubt.",
3162
3362
  "Long jobs: tools wait by default (up to timeoutSeconds, 600 by default) and return ids you can poll with studio_get_job / studio_get_run if they time out.",
3163
- "Authoring a workflow is free and revisioned: studio_workflow_describe for the vocabulary, studio_workflow_create to build one from ops, studio_workflow_apply to change it on top of a baseRevision (a stale_revision result means re-read, rebase, retry), studio_workflow_validate to dry-run before spending, then studio_run_workflow with its savedId."
3363
+ "Authoring a workflow is free and revisioned: studio_workflow_describe for the vocabulary, studio_workflow_create to build one from ops, studio_workflow_apply to change it on top of a baseRevision (a stale_revision result means re-read, rebase, retry), studio_workflow_validate to dry-run before spending, then studio_run_workflow with its savedId.",
3364
+ "Workflow hygiene: one deliverable is ONE saved workflow with a node per item, and a refinement of an item (edit, upscale, variation) is a NEW NODE appended to that workflow with studio_workflow_apply (addNode plus a connect from the node it refines, so the earlier generation is its input), then studio_run_workflow with savedId and nodeIds limited to the new node. Never send a fresh inline definition for a follow-up: every listed inline run is saved as its own workflow, which scatters one deliverable across duplicates."
3164
3365
  ].join(" ")
3165
3366
  }
3166
3367
  );