@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/index.js CHANGED
@@ -162,8 +162,8 @@ var StudioClient = class {
162
162
  }
163
163
  /** S9: a rendered diagram of a saved workflow's document, so an agent can
164
164
  * see the graph it just built. */
165
- getWorkflowScreenshot(savedId2) {
166
- return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}/screenshot`);
165
+ getWorkflowScreenshot(savedId2, opts = {}) {
166
+ return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}/screenshot${projectQuery(opts.projectId)}`);
167
167
  }
168
168
  // ── Recipes (S10): published workflow + closed post-run effect list ──
169
169
  publishWorkflow(savedId2, body) {
@@ -188,9 +188,10 @@ var StudioClient = class {
188
188
  describeNodeTypes() {
189
189
  return this.json("GET", "/api/v1/node-types");
190
190
  }
191
- /** `savedId` carries a `saved:` prefix; the colon is URL-encoded. */
192
- getWorkflowDocument(savedId2) {
193
- return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}`);
191
+ /** `savedId` carries a `saved:` prefix; the colon is URL-encoded. `projectId`
192
+ * names the project the workflow lives in; absent, the Studio's default. */
193
+ getWorkflowDocument(savedId2, opts = {}) {
194
+ return this.json("GET", `/api/v1/workflows/${encodeURIComponent(savedId2)}${projectQuery(opts.projectId)}`);
194
195
  }
195
196
  /** Create a document by applying `ops` to an empty one (the same code path as apply). */
196
197
  createWorkflow(body) {
@@ -361,6 +362,9 @@ function mimeForFilename(name) {
361
362
  return "application/octet-stream";
362
363
  }
363
364
  }
365
+ function projectQuery(projectId) {
366
+ return projectId === void 0 ? "" : `?projectId=${encodeURIComponent(projectId)}`;
367
+ }
364
368
 
365
369
  // src/config.ts
366
370
  var DEFAULT_BASE_URL = "https://studio.lumitra.co";
@@ -436,6 +440,10 @@ var MODEL_TASKS = [
436
440
  "speech-to-speech",
437
441
  "image-to-text",
438
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",
439
447
  // A human decision gate over candidate images (P3 J1). Still a model task,
440
448
  // not a new node kind: WorkflowNode already requires task + model, and the
441
449
  // ranking call underneath is the same image-to-json vision model
@@ -480,6 +488,8 @@ var TASK_PORTS = {
480
488
  // VISION + LANGUAGE.
481
489
  "image-to-text": { inputs: ["image", "text"], output: "text" },
482
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" },
483
493
  // Judge (P3 J1): one candidate wire in (the several results of ONE
484
494
  // upstream node-run, not fan-in), the approved candidate out. Both sides
485
495
  // are typed 'image' so an approved candidate can feed straight into any
@@ -543,11 +553,21 @@ var RefBindingSchema = z2.object({
543
553
  // the "no array-of-refs" gap without widening the binding grammar itself.
544
554
  wrap: z2.literal("array").optional()
545
555
  });
546
- var BindingSchema = z2.discriminatedUnion("kind", [
556
+ var ListItemBindingSchema = z2.discriminatedUnion("kind", [
547
557
  LiteralBindingSchema,
548
558
  ParamBindingSchema,
549
559
  RefBindingSchema
550
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
+ ]);
551
571
  var WorkflowTaskSchema = z2.enum(MODEL_TASKS);
552
572
  var RetryPolicySchema = z2.object({
553
573
  maxAttempts: z2.number().int().positive(),
@@ -626,6 +646,7 @@ var AssetIdSchema = z3.string().max(128);
626
646
  var PortTypeSchema = z3.enum(PORT_TYPES);
627
647
  var InputTypeSchema = z3.enum(INPUT_PORT_TYPES);
628
648
  var PositionSchema = z3.object({ x: z3.number().finite(), y: z3.number().finite() });
649
+ var CollapsedSchema = z3.boolean();
629
650
  var TaskNodeSchema = z3.object({
630
651
  id: IdSchema,
631
652
  kind: z3.literal("task"),
@@ -646,13 +667,15 @@ var TaskNodeSchema = z3.object({
646
667
  bakedInputs: z3.record(KeySchema, LiteralBindingSchema).default({}),
647
668
  /** The generation id that feeds downstream. Absent = latest (D4). */
648
669
  activeGeneration: IdSchema.optional(),
649
- label: LabelSchema.optional()
670
+ label: LabelSchema.optional(),
671
+ collapsed: CollapsedSchema.optional()
650
672
  });
651
673
  var PromptSourceNodeSchema = z3.object({
652
674
  id: IdSchema,
653
675
  kind: z3.literal("prompt-source"),
654
676
  text: PromptTextSchema.default(""),
655
- label: LabelSchema.optional()
677
+ label: LabelSchema.optional(),
678
+ collapsed: CollapsedSchema.optional()
656
679
  });
657
680
  var ImageSourceNodeSchema = z3.object({
658
681
  id: IdSchema,
@@ -663,7 +686,18 @@ var ImageSourceNodeSchema = z3.object({
663
686
  /** The Storage Brain asset id, when known (E18: every image input resolves to
664
687
  * an asset id). */
665
688
  assetId: AssetIdSchema.optional(),
666
- label: LabelSchema.optional()
689
+ label: LabelSchema.optional(),
690
+ collapsed: CollapsedSchema.optional()
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()
667
701
  });
668
702
  var InputNodeSchema = z3.object({
669
703
  id: IdSchema,
@@ -678,12 +712,14 @@ var InputNodeSchema = z3.object({
678
712
  /** Used when a run omits the input. Never null: absent means no default. */
679
713
  default: JsonNonNullValueSchema.optional(),
680
714
  label: LabelSchema.optional(),
681
- description: DescriptionSchema.optional()
715
+ description: DescriptionSchema.optional(),
716
+ collapsed: CollapsedSchema.optional()
682
717
  });
683
718
  var DocNodeSchema = z3.discriminatedUnion("kind", [
684
719
  TaskNodeSchema,
685
720
  PromptSourceNodeSchema,
686
721
  ImageSourceNodeSchema,
722
+ VideoSourceNodeSchema,
687
723
  InputNodeSchema
688
724
  ]);
689
725
  var DocEdgeSchema = z3.object({
@@ -696,6 +732,18 @@ var DocGroupSchema = z3.object({
696
732
  label: LabelSchema.optional(),
697
733
  nodeIds: z3.array(IdSchema).min(1)
698
734
  });
735
+ var EDGE_ROUTINGS = ["curved", "orthogonal"];
736
+ var EdgeRoutingSchema = z3.enum(EDGE_ROUTINGS);
737
+ var WireRouteSchema = z3.object({
738
+ from: PositionSchema,
739
+ to: PositionSchema,
740
+ points: z3.array(PositionSchema).max(64)
741
+ });
742
+ var DocumentViewSchema = z3.object({
743
+ edgeRouting: EdgeRoutingSchema.optional(),
744
+ /** Routed wires from the last Arrange, keyed by `edgeKey`. */
745
+ wires: z3.record(z3.string().max(512), WireRouteSchema).optional()
746
+ });
699
747
  var WorkflowDocumentSchema = z3.object({
700
748
  schema: z3.number().int().positive(),
701
749
  id: IdSchema,
@@ -703,7 +751,10 @@ var WorkflowDocumentSchema = z3.object({
703
751
  nodes: z3.array(DocNodeSchema).max(2e3),
704
752
  edges: z3.array(DocEdgeSchema).max(8e3),
705
753
  layout: z3.record(IdSchema, PositionSchema).default({}),
706
- groups: z3.array(DocGroupSchema).max(500).default([])
754
+ groups: z3.array(DocGroupSchema).max(500).default([]),
755
+ /** Display preferences; absent on every document written before they
756
+ * existed (additive, so `DOCUMENT_SCHEMA_VERSION` is unchanged). */
757
+ view: DocumentViewSchema.optional()
707
758
  });
708
759
  function edgeKey(e) {
709
760
  return JSON.stringify([e.source, e.target, e.port]);
@@ -955,7 +1006,7 @@ var MODEL_CATALOG = [
955
1006
  label: "Kling 2.5 Turbo Pro (image-to-video)",
956
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.",
957
1008
  cost: { usd: 0.35 },
958
- capabilities: { needsImageInput: true, maxInputImages: 1 },
1009
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true },
959
1010
  isDefault: true
960
1011
  },
961
1012
  {
@@ -976,7 +1027,7 @@ var MODEL_CATALOG = [
976
1027
  label: "Seedance 2.5 (image-to-video)",
977
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.",
978
1029
  cost: { usd: 1.89 },
979
- capabilities: { needsImageInput: true, maxInputImages: 1 }
1030
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
980
1031
  },
981
1032
  {
982
1033
  id: "fal/seedance-2-0-image-to-video",
@@ -986,7 +1037,7 @@ var MODEL_CATALOG = [
986
1037
  label: "Seedance 2.0 (image-to-video)",
987
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.",
988
1039
  cost: { usd: 1.89 },
989
- capabilities: { needsImageInput: true, maxInputImages: 1 }
1040
+ capabilities: { needsImageInput: true, maxInputImages: 1, acceptsEndFrame: true }
990
1041
  },
991
1042
  // ── FLUX.3 (Black Forest Labs) ───────────────────────────────────────────
992
1043
  // One model, three tiers of the same three input shapes:
@@ -1021,7 +1072,7 @@ var MODEL_CATALOG = [
1021
1072
  label: "FLUX.3 draft (first + last frame)",
1022
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.",
1023
1074
  cost: { usd: 0.3 },
1024
- capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true }
1075
+ capabilities: { needsImageInput: true, maxInputImages: 2, emitsDraftCache: true, acceptsEndFrame: true }
1025
1076
  },
1026
1077
  {
1027
1078
  id: "fal/flux-3-keyframes-to-video-draft",
@@ -1051,7 +1102,7 @@ var MODEL_CATALOG = [
1051
1102
  label: "FLUX.3 (first + last frame)",
1052
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.",
1053
1104
  cost: { usd: 0.85 },
1054
- capabilities: { needsImageInput: true, maxInputImages: 2 }
1105
+ capabilities: { needsImageInput: true, maxInputImages: 2, acceptsEndFrame: true }
1055
1106
  },
1056
1107
  {
1057
1108
  id: "fal/flux-3-keyframes-to-video",
@@ -1121,6 +1172,19 @@ var MODEL_CATALOG = [
1121
1172
  capabilities: { needsImageInput: true, maxInputImages: 1 },
1122
1173
  isDefault: true
1123
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
+ },
1124
1188
  {
1125
1189
  id: "fal/moondream-next-vqa",
1126
1190
  provider: "fal",
@@ -1312,6 +1376,9 @@ function portBinding(task, port) {
1312
1376
  if (port === "mesh3d" && (task === "remesh" || task === "texture")) {
1313
1377
  return { key: "parentAssetId", path: "assetId" };
1314
1378
  }
1379
+ if (port === "video" && task === "video-to-text") {
1380
+ return { key: "inputVideoUrl", path: "url" };
1381
+ }
1315
1382
  if (port === "video" && task === "video-to-video") {
1316
1383
  return { key: "inputVideoUrl", path: "url" };
1317
1384
  }
@@ -1342,6 +1409,30 @@ function canConnect(sourceOut, targetTask, targetPort) {
1342
1409
  if (sourceOut !== targetPort) return false;
1343
1410
  return portBinding(targetTask, targetPort) !== null;
1344
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
+ }
1345
1436
  function sourcePortOf(node) {
1346
1437
  switch (node.kind) {
1347
1438
  case "task":
@@ -1350,10 +1441,28 @@ function sourcePortOf(node) {
1350
1441
  return "text";
1351
1442
  case "image-source":
1352
1443
  return "image";
1444
+ case "video-source":
1445
+ return "video";
1353
1446
  case "input":
1354
1447
  return node.type;
1355
1448
  }
1356
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
+ }
1357
1466
  function wireLegality(source, target, port) {
1358
1467
  if (source.id === target.id) return { ok: false, code: "cycle", message: `node "${source.id}" cannot wire into itself` };
1359
1468
  const produced = sourcePortOf(source);
@@ -1420,7 +1529,7 @@ function validateDocument(doc) {
1420
1529
  inputNames.add(node.name);
1421
1530
  }
1422
1531
  const edgeKeys = /* @__PURE__ */ new Set();
1423
- const slots = /* @__PURE__ */ new Set();
1532
+ const slots = /* @__PURE__ */ new Map();
1424
1533
  for (const e of doc.edges) {
1425
1534
  const key = edgeKey(e);
1426
1535
  if (edgeKeys.has(key)) return { ok: false, code: "conflict", message: `duplicate edge ${key}` };
@@ -1432,14 +1541,24 @@ function validateDocument(doc) {
1432
1541
  const legal = wireLegality(source, target, e.port);
1433
1542
  if (!legal.ok) return legal;
1434
1543
  const slot = slotKey(e.target, e.port);
1435
- 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) {
1436
1556
  return {
1437
1557
  ok: false,
1438
- code: "cardinality",
1439
- 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.`
1440
1560
  };
1441
1561
  }
1442
- slots.add(slot);
1443
1562
  }
1444
1563
  if (documentTopologicalOrder(doc) === null) {
1445
1564
  return { ok: false, code: "cycle", message: `document "${doc.id}" has a cycle in its wires` };
@@ -1472,16 +1591,25 @@ var TaskNodePropsSchema = z4.object({
1472
1591
  task: z4.enum(MODEL_TASKS),
1473
1592
  model: ModelIdSchema,
1474
1593
  prompt: PromptTextSchema,
1475
- label: LabelSchema.nullable()
1594
+ label: LabelSchema.nullable(),
1595
+ collapsed: CollapsedSchema.nullable()
1476
1596
  }).partial().strict();
1477
1597
  var PromptSourcePropsSchema = z4.object({
1478
1598
  text: PromptTextSchema,
1479
- label: LabelSchema.nullable()
1599
+ label: LabelSchema.nullable(),
1600
+ collapsed: CollapsedSchema.nullable()
1480
1601
  }).partial().strict();
1481
1602
  var ImageSourcePropsSchema = z4.object({
1482
1603
  imageUrl: UrlSchema,
1483
1604
  assetId: AssetIdSchema.nullable(),
1484
- label: LabelSchema.nullable()
1605
+ label: LabelSchema.nullable(),
1606
+ collapsed: CollapsedSchema.nullable()
1607
+ }).partial().strict();
1608
+ var VideoSourcePropsSchema = z4.object({
1609
+ videoUrl: UrlSchema,
1610
+ assetId: AssetIdSchema.nullable(),
1611
+ label: LabelSchema.nullable(),
1612
+ collapsed: CollapsedSchema.nullable()
1485
1613
  }).partial().strict();
1486
1614
  var InputNodePropsSchema = z4.object({
1487
1615
  name: InputNameSchema,
@@ -1490,14 +1618,19 @@ var InputNodePropsSchema = z4.object({
1490
1618
  cardinality: z4.enum(["one", "many"]).nullable(),
1491
1619
  default: JsonNonNullValueSchema.nullable(),
1492
1620
  label: LabelSchema.nullable(),
1493
- description: DescriptionSchema.nullable()
1621
+ description: DescriptionSchema.nullable(),
1622
+ collapsed: CollapsedSchema.nullable()
1494
1623
  }).partial().strict();
1495
1624
  var GroupPropsSchema = z4.object({
1496
1625
  label: LabelSchema.nullable(),
1497
1626
  nodeIds: z4.array(IdSchema).min(1)
1498
1627
  }).partial().strict();
1499
1628
  var DocumentPropsSchema = z4.object({
1500
- label: LabelSchema
1629
+ label: LabelSchema,
1630
+ /** Display preferences (`view.edgeRouting`); null clears the whole
1631
+ * object. The object is replaced, not merged: pass every field you want
1632
+ * kept. */
1633
+ view: DocumentViewSchema.strict().nullable()
1501
1634
  }).partial().strict();
1502
1635
  var AddNodeOpSchema = z4.object({
1503
1636
  type: z4.literal("addNode"),
@@ -1515,6 +1648,7 @@ var SetNodePropsOpSchema = z4.discriminatedUnion("kind", [
1515
1648
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("task"), id: IdSchema, props: TaskNodePropsSchema }),
1516
1649
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("prompt-source"), id: IdSchema, props: PromptSourcePropsSchema }),
1517
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 }),
1518
1652
  z4.object({ type: z4.literal("setNodeProps"), kind: z4.literal("input"), id: IdSchema, props: InputNodePropsSchema })
1519
1653
  ]);
1520
1654
  var SetNodeSettingOpSchema = z4.object({
@@ -1546,8 +1680,8 @@ var DisconnectOpSchema = z4.object({
1546
1680
  });
1547
1681
  var ExposeAsInputOpSchema = z4.object({
1548
1682
  type: z4.literal("exposeAsInput"),
1549
- /** A prompt-source or image-source node; it becomes an input node IN PLACE
1550
- * (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. */
1551
1685
  id: IdSchema,
1552
1686
  name: InputNameSchema,
1553
1687
  required: z4.boolean().optional(),
@@ -1560,8 +1694,9 @@ var ExposeAsInputOpSchema = z4.object({
1560
1694
  });
1561
1695
  var FreezeInputOpSchema = z4.object({
1562
1696
  type: z4.literal("freezeInput"),
1563
- /** A text or image input node; it becomes a prompt-source / image-source IN
1564
- * 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). */
1565
1700
  id: IdSchema,
1566
1701
  value: PromptTextSchema,
1567
1702
  assetId: AssetIdSchema.optional(),
@@ -1770,6 +1905,8 @@ var TASK_TO_KIND = {
1770
1905
  // vision (text/json output, no binary asset)
1771
1906
  "image-to-text": "analyze_image",
1772
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",
1773
1910
  // utility (new Image asset off the source)
1774
1911
  upscale: "transform_image",
1775
1912
  "background-remove": "transform_image",
@@ -1793,7 +1930,7 @@ function taskExecution(task) {
1793
1930
  function paramTypeForInput(type) {
1794
1931
  return type === "json" ? "json" : "string";
1795
1932
  }
1796
- function isUsableImageUrl(url) {
1933
+ function isUsableSourceUrl(url) {
1797
1934
  return url.length > 0 && !url.startsWith("data:");
1798
1935
  }
1799
1936
  function documentToDefinition(doc, opts = {}) {
@@ -1851,22 +1988,50 @@ function documentToDefinition(doc, opts = {}) {
1851
1988
  issues.push({ code: "prompt_required", nodeId: node.id, message: `Node "${node.id}" (${task}) needs a prompt.` });
1852
1989
  }
1853
1990
  }
1991
+ const byPort = /* @__PURE__ */ new Map();
1854
1992
  for (const e of wires) {
1855
1993
  if (e.port === "text") continue;
1856
- const b = portBinding(task, e.port);
1857
- const source = byId.get(e.source);
1858
- if (source.kind === "image-source") {
1859
- const url = source.imageUrl.trim();
1860
- if (!isUsableImageUrl(url)) {
1861
- issues.push({ code: "source_uploading", nodeId: node.id, message: `Image source for "${node.id}" is still uploading; wait for it to finish.` });
1862
- 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 };
1863
2029
  }
1864
- inputs[b.key] = { kind: "literal", value: b.wrap === "array" ? [url] : url };
1865
- } else if (source.kind === "input") {
1866
- inputs[b.key] = b.wrap === "array" && source.cardinality !== "many" ? { kind: "param", name: source.name, wrap: "array" } : { kind: "param", name: source.name };
1867
- } else if (source.kind === "task") {
1868
- 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);
1869
2033
  }
2034
+ if (!uploading && items.length > 0) inputs[b.key] = { kind: "list", items };
1870
2035
  }
1871
2036
  for (const [key, binding] of Object.entries(node.bakedInputs)) if (!(key in inputs)) inputs[key] = binding;
1872
2037
  if (Object.keys(inputs).length === 0 && !promptMissing) {
@@ -2071,7 +2236,7 @@ async function pollUntil(what, fetchState, isDone, opts = {}) {
2071
2236
  }
2072
2237
  }
2073
2238
  var TERMINAL_JOB_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "canceled"]);
2074
- var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "partial"]);
2239
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed", "partial", "cancelled"]);
2075
2240
  function isJobTerminal(res) {
2076
2241
  return TERMINAL_JOB_STATUSES.has(res.job.status);
2077
2242
  }
@@ -2104,8 +2269,24 @@ function runFailureSummary(res) {
2104
2269
  var DEFAULT_SWAP_EDIT_MODEL = "fal/nano-banana-2-edit";
2105
2270
  var DEFAULT_SWAP_VIDEO_MODEL = "fal/seedance-pro-image-to-video";
2106
2271
  var KEYFRAME_SWAP_VIDEO_MODEL = "fal/kling-v2-5-image-to-video";
2107
- 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.";
2108
- 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.";
2109
2290
  var SWAP_EDIT_NODE_ID = "edit";
2110
2291
  var SWAP_VIDEO_NODE_ID = "video";
2111
2292
  function defaultVideoModelFor(input) {
@@ -2125,7 +2306,7 @@ function buildCharacterSwapDefinition(input) {
2125
2306
  task: "image-edit",
2126
2307
  model: input.editModel ?? DEFAULT_SWAP_EDIT_MODEL,
2127
2308
  inputs: {
2128
- prompt: { kind: "literal", value: input.swapPrompt ?? DEFAULT_SWAP_PROMPT },
2309
+ prompt: { kind: "literal", value: input.swapPrompt ?? composeSwapPrompt({ scope: input.scope, preserve: input.preserve }) },
2129
2310
  inputImages: { kind: "literal", value: [input.sceneFrameUrl, input.characterImageUrl] },
2130
2311
  aspectRatio: { kind: "literal", value: input.aspectRatio ?? "16:9" }
2131
2312
  }
@@ -2234,6 +2415,9 @@ function summarizeNode(node, wires) {
2234
2415
  case "image-source":
2235
2416
  base.imageUrl = clipText(node.imageUrl);
2236
2417
  break;
2418
+ case "video-source":
2419
+ base.videoUrl = clipText(node.videoUrl);
2420
+ break;
2237
2421
  case "input":
2238
2422
  base.name = node.name;
2239
2423
  base.type = node.type;
@@ -2404,11 +2588,12 @@ var studioWorkflowGet = defineTool({
2404
2588
  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.",
2405
2589
  inputSchema: {
2406
2590
  savedId,
2591
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project."),
2407
2592
  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.")
2408
2593
  },
2409
2594
  annotations: { readOnlyHint: true },
2410
2595
  handler: async (args, ctx) => {
2411
- const res = await ctx.client.getWorkflowDocument(args.savedId);
2596
+ const res = await ctx.client.getWorkflowDocument(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2412
2597
  if (args.full) return res;
2413
2598
  if (res.readOnly === true || res.document === null) {
2414
2599
  const definition = isRecord(res.definition) ? res.definition : null;
@@ -2481,6 +2666,7 @@ var studioWorkflowApply = defineTool({
2481
2666
  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.',
2482
2667
  inputSchema: {
2483
2668
  savedId,
2669
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project."),
2484
2670
  baseRevision: PatchSchema.shape.baseRevision.describe("The revision the ops were written against. The Studio refuses a stale one with stale_revision."),
2485
2671
  ops: opsSchema,
2486
2672
  idempotencyKey: PatchSchema.shape.idempotencyKey.describe(
@@ -2495,6 +2681,7 @@ var studioWorkflowApply = defineTool({
2495
2681
  let res;
2496
2682
  try {
2497
2683
  res = await ctx.client.applyWorkflowPatch(args.savedId, {
2684
+ ...args.projectId !== void 0 ? { projectId: args.projectId } : {},
2498
2685
  baseRevision: args.baseRevision,
2499
2686
  ops: args.ops,
2500
2687
  idempotencyKey,
@@ -2529,16 +2716,18 @@ var studioWorkflowValidate = defineTool({
2529
2716
  inputSchema: {
2530
2717
  document: WorkflowDocumentSchema.optional().describe("The document to validate. Either this or savedId."),
2531
2718
  savedId: savedId.optional().describe("Fetch this saved workflow first and validate against its current document. Either this or document, not both."),
2719
+ 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."),
2532
2720
  ops: PatchSchema.shape.ops.optional().describe("Ops to apply to the document before projecting it (the same vocabulary and limits as studio_workflow_apply)."),
2533
2721
  params: paramsSchema.optional()
2534
2722
  },
2535
2723
  annotations: { readOnlyHint: true },
2536
2724
  handler: async (args, ctx) => {
2537
2725
  if (args.document && args.savedId) throw new ToolInputError("pass either `document` or `savedId`, not both");
2726
+ if (args.projectId !== void 0 && !args.savedId) throw new ToolInputError("`projectId` only applies when fetching by `savedId`");
2538
2727
  let document = args.document;
2539
2728
  if (!document) {
2540
2729
  if (!args.savedId) throw new ToolInputError("provide a `document` or a `savedId` to validate");
2541
- const saved = await ctx.client.getWorkflowDocument(args.savedId);
2730
+ const saved = await ctx.client.getWorkflowDocument(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2542
2731
  if (saved.readOnly === true || saved.document === null) {
2543
2732
  throw new ToolInputError(
2544
2733
  `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.`
@@ -2557,11 +2746,12 @@ var studioWorkflowScreenshot = defineTool({
2557
2746
  name: "studio_workflow_screenshot",
2558
2747
  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.",
2559
2748
  inputSchema: {
2560
- savedId
2749
+ savedId,
2750
+ projectId: z7.string().min(1).optional().describe("Project id from studio_list_projects the workflow lives in; omit for the default project.")
2561
2751
  },
2562
2752
  annotations: { readOnlyHint: true },
2563
2753
  handler: async (args, ctx) => {
2564
- return ctx.client.getWorkflowScreenshot(args.savedId);
2754
+ return ctx.client.getWorkflowScreenshot(args.savedId, args.projectId !== void 0 ? { projectId: args.projectId } : {});
2565
2755
  }
2566
2756
  });
2567
2757
  var WORKFLOW_TOOLS = [
@@ -2594,6 +2784,8 @@ function projectJob(res) {
2594
2784
  function projectNodeRun(nr) {
2595
2785
  return {
2596
2786
  nodeId: nr.nodeId,
2787
+ // The node-run (generation) id: what `upstreamGenerations` pins.
2788
+ id: nr.id ?? null,
2597
2789
  status: nr.status,
2598
2790
  url: nr.resultUrl ?? nr.results?.[0]?.url ?? null,
2599
2791
  kind: nr.results?.[0]?.kind ?? null,
@@ -2847,17 +3039,20 @@ var studioUploadBrandReference = defineTool({
2847
3039
  }
2848
3040
  });
2849
3041
  var workflowDefinitionSchema = WorkflowDefinitionSchema.describe(
2850
- '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).'
2851
3043
  );
2852
3044
  var studioRunWorkflow = defineTool({
2853
3045
  name: "studio_run_workflow",
2854
- 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.`,
3046
+ 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.`,
2855
3047
  inputSchema: {
2856
3048
  projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
2857
3049
  definition: workflowDefinitionSchema.optional(),
2858
3050
  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."),
2859
3051
  params: z8.record(z8.string(), z8.unknown()).optional().describe("Values for the definition's params."),
2860
3052
  nodeIds: z8.array(z8.string()).optional().describe("Run only this subset of nodes (requires an inline `definition` or a `savedId`)."),
3053
+ upstreamGenerations: z8.record(z8.string(), z8.string()).optional().describe(
3054
+ '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.'
3055
+ ),
2861
3056
  runs: z8.number().int().min(1).max(8).optional().describe("Repeat a node subset N times (1 to 8)."),
2862
3057
  visibility: z8.enum(["listed", "unlisted"]).optional().describe("Whether the run shows in the Studio workflow list."),
2863
3058
  budgetUsd: z8.number().nonnegative().optional().describe(
@@ -2881,6 +3076,7 @@ var studioRunWorkflow = defineTool({
2881
3076
  ...args.definition ? { definition: args.definition } : {},
2882
3077
  ...args.params ? { params: args.params } : {},
2883
3078
  ...args.nodeIds && args.nodeIds.length > 0 ? { nodeIds: args.nodeIds } : {},
3079
+ ...args.upstreamGenerations ? { upstreamGenerations: args.upstreamGenerations } : {},
2884
3080
  ...args.runs !== void 0 ? { runs: args.runs } : {},
2885
3081
  ...args.savedId ? { savedId: args.savedId } : {},
2886
3082
  ...args.visibility ? { visibility: args.visibility } : {},
@@ -2894,7 +3090,7 @@ var studioRunWorkflow = defineTool({
2894
3090
  });
2895
3091
  var studioGetRun = defineTool({
2896
3092
  name: "studio_get_run",
2897
- 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.",
3093
+ 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.",
2898
3094
  inputSchema: {
2899
3095
  runId: z8.string().min(1),
2900
3096
  wait: z8.boolean().default(false),
@@ -2941,14 +3137,16 @@ var studioGetRunOutput = defineTool({
2941
3137
  });
2942
3138
  var studioReplaceCharacterInVideo = defineTool({
2943
3139
  name: "studio_replace_character_in_video",
2944
- 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.',
2945
3141
  inputSchema: {
2946
3142
  projectId: z8.string().min(1).describe("Project id from studio_list_projects."),
2947
3143
  sceneFrameUrl: httpUrl.describe("Public or signed URL of ONE frame of the target shot (png/jpg)."),
2948
3144
  characterSlug: slug.optional().describe("Minted character to insert; its full-body reference (fallback three-quarter, face) is resolved automatically."),
2949
3145
  characterReferenceUrl: httpUrl.optional().describe("Explicit character reference image URL (use instead of characterSlug)."),
2950
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."),
2951
- 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."),
2952
3150
  motionPrompt: z8.string().optional().describe("Override the motion description for the video node."),
2953
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.`),
2954
3152
  editModel: z8.string().default(DEFAULT_SWAP_EDIT_MODEL).describe("Image-edit model id for the still swap."),
@@ -2979,6 +3177,8 @@ var studioReplaceCharacterInVideo = defineTool({
2979
3177
  characterImageUrl,
2980
3178
  ...args.endFrameUrl ? { endFrameUrl: args.endFrameUrl } : {},
2981
3179
  ...args.swapPrompt ? { swapPrompt: args.swapPrompt } : {},
3180
+ ...args.scope ? { scope: args.scope } : {},
3181
+ ...args.preserve ? { preserve: args.preserve } : {},
2982
3182
  ...args.motionPrompt ? { motionPrompt: args.motionPrompt } : {},
2983
3183
  editModel: args.editModel,
2984
3184
  videoModel,
@@ -3141,7 +3341,7 @@ function registerTools(server, ctx) {
3141
3341
 
3142
3342
  // src/server.ts
3143
3343
  var SERVER_NAME = "lumitra-studio";
3144
- var SERVER_VERSION = "0.1.0";
3344
+ var SERVER_VERSION = "0.2.0";
3145
3345
  function createStudioMcpServer(opts = {}) {
3146
3346
  const config = opts.config ?? resolveConfig(opts.env ?? process.env);
3147
3347
  const client = new StudioClient(config, {
@@ -3155,7 +3355,8 @@ function createStudioMcpServer(opts = {}) {
3155
3355
  "Lumitra Studio: brand-consistent image generation, character minting, workflow runs and the still-swap video recipe over the hosted Studio API.",
3156
3356
  "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.",
3157
3357
  "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.",
3158
- "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."
3358
+ "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.",
3359
+ "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."
3159
3360
  ].join(" ")
3160
3361
  }
3161
3362
  );
@@ -3312,6 +3513,7 @@ export {
3312
3513
  SERVER_NAME,
3313
3514
  SERVER_VERSION,
3314
3515
  SUMMARY_TEXT_CHARS,
3516
+ SWAP_SCOPES,
3315
3517
  StudioApiError,
3316
3518
  StudioClient,
3317
3519
  TOOLS,
@@ -3323,6 +3525,7 @@ export {
3323
3525
  buildCharacterSwapDefinition,
3324
3526
  categoryForNode,
3325
3527
  characterSheetDefinition,
3528
+ composeSwapPrompt,
3326
3529
  createHttpHandler,
3327
3530
  createStudioMcpServer,
3328
3531
  invokeTool,