voidless-mcp 1.6.12 → 1.6.15

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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/tools.js +150 -33
package/README.md CHANGED
@@ -74,7 +74,7 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
74
74
  | `list_assets` | Your assets, newest first, filterable by type or tag |
75
75
  | `get_asset` | One asset with every frame: animations, 8-way rotations, edits and parts, with the ids and image keys the other tools take |
76
76
  | `check_generation` | The result of a long generation (animation sets, video, 3D) that was still running when its tool returned. Free |
77
- | `get_animation_presets` | The animation-set presets and what each pass costs |
77
+ | `get_animation_presets` | The animation-set presets and what each clip costs |
78
78
 
79
79
  **Tagging** (free)
80
80
 
@@ -89,6 +89,8 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
89
89
  | --- | --- |
90
90
  | `generate_image` | Sprite, character, portrait, prop, tileset, UI piece or artwork in a chosen model, style, palette, size and perspective; artwork can be 16:9 for backdrops |
91
91
  | `edit_image` | Change an image with an instruction; the edit is added as a new frame |
92
+ | `make_normal_map` | A normal map for an image, an edit or every frame of an animation, for 2D lights in a game engine; free |
93
+ | `make_glow_map` | The light an edit added (lit windows, a night version's lamps) on its own, for an additive glow layer; free |
92
94
  | `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass |
93
95
  | `extract_parts` | Split a character into body parts for rigging |
94
96
  | `upload_image` | Add an image you already have to your library |
@@ -100,7 +102,7 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
100
102
  | Tool | What it does |
101
103
  | --- | --- |
102
104
  | `animate_sprite` | One 16-frame clip from a description of the motion |
103
- | `generate_animation_set` | Several clips in one job: a preset (platformer, top-down walk/run/dash/attack/idle) on one sprite, or one directional clip per facing of an 8-way set; the left side is mirrored from the right (`autoFlip` to mirror any right-only clips after the run) |
105
+ | `generate_animation_set` | Several clips in one job, 15 credits each: a preset (platformer, top-down walk/run/dash/attack/idle) on one or more sprites, one directional clip per facing of an 8-way set (the left side mirrored from the right for free; `autoFlip` mirrors any right-only clips after the run), or your own list of actions. Loops end on their first frame |
104
106
  | `get_image_data` | An image, or an animation as one sprite strip, as a small data URI to paste into HTML (for sandboxes such as Claude's HTML artifacts that cannot load outside images), with a CSS snippet to play it |
105
107
  | `flip_left_side` | Mirror the right-side animations into the left side (walk-right -> walk-left), free |
106
108
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "voidless-mcp",
3
3
  "mcpName": "dev.voidless/voidless",
4
- "version": "1.6.12",
4
+ "version": "1.6.15",
5
5
  "description": "MCP server for Voidless: generate game sprites, animations, sound, music, video and 3D models",
6
6
  "license": "MIT",
7
7
  "author": "Voidless Dev Inc. <contact@voidless.dev>",
package/tools.js CHANGED
@@ -27,7 +27,7 @@ import { Agent, setGlobalDispatcher } from "undici";
27
27
  // server does.
28
28
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
29
29
 
30
- export const VERSION = "1.6.12";
30
+ export const VERSION = "1.6.15";
31
31
  const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
32
32
  const CDN_URL = "https://cdn.voidless.dev";
33
33
  // Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
@@ -46,7 +46,7 @@ const MAX_INLINE_IMAGES = 8;
46
46
  const GALLERY_URI = "ui://voidless/gallery.html";
47
47
  const GALLERY_MIME = "text/html;profile=mcp-app";
48
48
  const GALLERY_TOOLS = [
49
- "generate_image", "upload_image", "edit_image", "rotate_sprite", "animate_sprite", "generate_animation_set",
49
+ "generate_image", "upload_image", "edit_image", "make_normal_map", "make_glow_map", "rotate_sprite", "animate_sprite", "generate_animation_set",
50
50
  "flip_left_side", "generate_video", "generate_sfx", "generate_music", "generate_3d_model", "get_asset",
51
51
  "list_assets", "extract_parts", "pixelate_image", "extend_video", "clean_up_sprite", "check_generation",
52
52
  ];
@@ -58,7 +58,7 @@ const GALLERY_HTML = readFileSync(new URL("./gallery.html", import.meta.url), "u
58
58
  // job is still going the tool says so and check_generation fetches the result later (Strapi keeps
59
59
  // it under the key: GET /generate/job/:key).
60
60
  const LONG_PATHS = new Set([
61
- "/generate/image", "/generate/image-edit", "/generate/image-rotations", "/generate/image-parts",
61
+ "/generate/image", "/generate/image-edit", "/generate/bbox-edit", "/generate/image-rotations", "/generate/image-parts",
62
62
  "/generate/image-animation", "/generate/multi-animation", "/generate/video", "/generate/video-extend",
63
63
  "/generate/sfx", "/generate/music", "/generate/3d-model", "/generate/model-rig", "/generate/model-animation",
64
64
  ]);
@@ -73,6 +73,25 @@ class PendingJob extends Error {
73
73
  }
74
74
  }
75
75
 
76
+ // A finished job whose full result the server no longer has (kept as a summary on its log): what it
77
+ // made, and where to look, instead of a result the tool could format.
78
+ class RestoredJob extends Error {
79
+ constructor(result) {
80
+ super(result?.message || "Finished.");
81
+ this.result = result;
82
+ }
83
+ }
84
+
85
+ function restoredText(result) {
86
+ const ids = Array.isArray(result?.assetIds) ? result.assetIds : [];
87
+ return [
88
+ `Done. ${result?.message || "The generation finished."}`,
89
+ ids.length ? `Asset${ids.length === 1 ? "" : "s"}: ${ids.join(", ")}` : null,
90
+ Array.isArray(result?.frameIds) && result.frameIds.length ? `New frame ids: ${result.frameIds.join(", ")}` : null,
91
+ "It has already been made and charged: do not run it again.",
92
+ ].filter(Boolean).join("\n");
93
+ }
94
+
76
95
  export function createVoidlessServer(API_KEY) {
77
96
  async function api(method, path, body, headers = {}) {
78
97
  const res = await fetch(`${BASE_URL}/api${path}`, {
@@ -117,6 +136,10 @@ export function createVoidlessServer(API_KEY) {
117
136
  if (out === null) throw new PendingJob(key, path);
118
137
  // The connection was cut but the job may well be running: check, do not report a failure.
119
138
  if (out.e) throw CUT_OFF.test(String(out.e.message || out.e)) ? new PendingJob(key, path) : out.e;
139
+ // The server already had this key (a resend): it answers with that job's state instead of
140
+ // running it again -- still going (202), or done but only a summary kept.
141
+ if (out.v && out.v.job === key && out.v.status === "running") throw new PendingJob(key, path);
142
+ if (out.v && out.v.restored) throw new RestoredJob(out.v);
120
143
  return out.v;
121
144
  } finally {
122
145
  clearTimeout(timer);
@@ -146,6 +169,7 @@ export function createVoidlessServer(API_KEY) {
146
169
  if (pending.size > 500) pending.delete(pending.keys().next().value);
147
170
  return pendingResult(err.key);
148
171
  }
172
+ if (err instanceof RestoredJob) return { content: [{ type: "text", text: restoredText(err.result) }] };
149
173
  return { isError: true, content: [{ type: "text", text: String(err.message || err) }] };
150
174
  }
151
175
  };
@@ -270,11 +294,25 @@ export function createVoidlessServer(API_KEY) {
270
294
  }
271
295
 
272
296
  // Multi-animation responses: { results: [{ assetId, frames, skipped? }], quality, cost, … }.
297
+ // "2 sprites × 7 clips": one line for the shape of the set.
298
+ function setSummary(data) {
299
+ const made = (data.results || []).filter((r) => !r.skipped && !r.mirrored);
300
+ if (!made.length) return null;
301
+ const perAsset = new Map();
302
+ for (const r of made) perAsset.set(r.assetId, (perAsset.get(r.assetId) || 0) + (r.frames || []).length);
303
+ const counts = [...perAsset.values()];
304
+ const total = counts.reduce((a, n) => a + n, 0);
305
+ const sprites = perAsset.size;
306
+ return counts.every((n) => n === counts[0])
307
+ ? `${sprites} sprite${sprites === 1 ? "" : "s"} × ${counts[0]} clip${counts[0] === 1 ? "" : "s"}`
308
+ : `${total} clips on ${sprites} sprite${sprites === 1 ? "" : "s"}`;
309
+ }
310
+
273
311
  async function resultsContent(data, label) {
274
312
  const content = [
275
313
  {
276
314
  type: "text",
277
- text: [label, data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
315
+ text: [label, setSummary(data), data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
278
316
  },
279
317
  ];
280
318
  let inlined = 0;
@@ -554,8 +592,11 @@ export function createVoidlessServer(API_KEY) {
554
592
  st = await apiGet(`/generate/job/${encodeURIComponent(job)}`);
555
593
  } catch (err) {
556
594
  if (/\(404\)/.test(String(err.message))) {
557
- return { content: [{ type: "text", text: `No job ${job} on record (jobs are kept for 2 hours). ` +
558
- "The result may already be in the library: list_assets or get_asset shows it." }] };
595
+ // Not a failure: the server has no record of the key, which says nothing about the job.
596
+ return { content: [{ type: "text", text: `The server has no record of job ${job}. That does NOT mean it ` +
597
+ "failed: it may well have finished and been charged. Before running anything again, look for the " +
598
+ "result with list_assets (newest first) or get_asset on the asset it was for, and ask the user " +
599
+ "before re-running the tool." }] };
559
600
  }
560
601
  throw err;
561
602
  }
@@ -567,6 +608,8 @@ export function createVoidlessServer(API_KEY) {
567
608
  const msg = st.result?.error?.message || st.result?.message || `HTTP ${st.httpStatus}`;
568
609
  return { isError: true, content: [{ type: "text", text: `The generation failed: ${msg}` }] };
569
610
  }
611
+ // Finished before a server restart, with only a summary kept: point at the asset.
612
+ if (st.result && st.result.restored) return { content: [{ type: "text", text: restoredText(st.result) }] };
570
613
  const p = pending.get(job);
571
614
  if (!p) {
572
615
  // This connector restarted since the job began: the raw result is all there is.
@@ -613,18 +656,19 @@ export function createVoidlessServer(API_KEY) {
613
656
  server.tool(
614
657
  "get_animation_presets",
615
658
  "List the animation-set presets (platformer-character, topdown-walk/run/dash/attack/idle) with the " +
616
- "actions each one makes, and the quality tiers with their credit cost per pass. Free.",
659
+ "actions each one makes, and the credit cost per clip. Free.",
617
660
  {},
618
661
  readTool("Get animation presets"),
619
662
  tool(async () => {
620
663
  const data = await apiGet("/generate/multi-animation/presets");
621
664
  const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
622
- const qualities = (data.qualities || []).map((q) => `- ${q.key}: ${q.resolution}, ${q.credits} credits per pass`);
665
+ const perClip = Number(data.pricePerClip) > 0 ? Number(data.pricePerClip) : 15;
623
666
  return {
624
667
  content: [
625
668
  {
626
669
  type: "text",
627
- text: `Presets:\n${presets.join("\n")}\n\nQuality (picked automatically from the sprite size):\n${qualities.join("\n")}\n\nUp to ${data.maxImages} images per run.`,
670
+ text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip (mirrored left-side clips are free). ` +
671
+ `Up to ${data.maxImages} images per run.`,
628
672
  },
629
673
  ],
630
674
  };
@@ -759,7 +803,7 @@ export function createVoidlessServer(API_KEY) {
759
803
  aspect: z
760
804
  .enum(["1:1", "16:9"])
761
805
  .default("1:1")
762
- .describe("type 'artwork' only: '16:9' for a backdrop, scene or game background (1344x756 at 1024, 672x378 at 512). Everything that is not artwork is always 1:1"),
806
+ .describe("type 'artwork' at size 512 or 1024 only: '16:9' for a backdrop, scene or game background (1344x756 at 1024, 672x378 at 512; pixel-art styles come out on a 4 px grid, 672x376 at 512). Smaller sizes, Retro Diffusion and everything that is not artwork are always 1:1"),
763
807
  uiMode: z
764
808
  .enum(["graphic", "title", "text"])
765
809
  .default("graphic")
@@ -797,6 +841,65 @@ export function createVoidlessServer(API_KEY) {
797
841
  tool(async (args) => framesResult(await apiPost("/generate/image-edit", { ...args, model: "p-image" }), args.id, "Edit"))
798
842
  );
799
843
 
844
+ server.tool(
845
+ "make_normal_map",
846
+ "Make a normal map for an image asset, for 2D lights in a game engine: which way each pixel faces, so a " +
847
+ "moving light shades it. The shape comes from a depth model, the drawn detail from the picture. PNG at the " +
848
+ "picture's own size, OpenGL style (green = up: Godot and Unity read it as is; flip green for Unreal). " +
849
+ "Pass frameId (from get_asset) for an edit, or an animation (one map per frame); leave it out for the " +
850
+ "image itself. Free.",
851
+ {
852
+ id: assetId,
853
+ frameId: z
854
+ .number()
855
+ .int()
856
+ .optional()
857
+ .describe("Optional: the frame (an edit or an animation, from get_asset) to make it for. Empty = the image itself."),
858
+ },
859
+ writeTool("Make normal map"),
860
+ tool(async ({ id, frameId: fid }) => {
861
+ const data = await apiPost("/generate/normal-map", { id, ...(fid === undefined ? {} : { frameId: fid }) });
862
+ const keys = data.maps?.normals || (data.maps?.normal ? [data.maps.normal] : []);
863
+ const content = [
864
+ {
865
+ type: "text",
866
+ text:
867
+ `Normal map${keys.length > 1 ? `s (${keys.length}, one per frame)` : ""} saved on asset ${id} ` +
868
+ `(${APP_URL}/asset/${id}):\n` + keys.map((k) => `${CDN_URL}/${k}`).join("\n"),
869
+ },
870
+ ];
871
+ const image = keys.length ? await inlineImage(keys[0]) : null;
872
+ if (image) content.push(image);
873
+ return { content, structuredContent: { summary: content[0].text } };
874
+ })
875
+ );
876
+
877
+ server.tool(
878
+ "make_glow_map",
879
+ "Make a glow map from an edit that switched lights on (or made a night version with lights glowing): the " +
880
+ "light the edit added over the picture it came from, on transparency, for an additive or emissive layer in " +
881
+ "a game. Same size as the picture. Pass the edit's frameId (from get_asset). Free.",
882
+ {
883
+ id: assetId,
884
+ frameId: z.number().int().describe("The edit (a frame from get_asset) whose light to keep"),
885
+ },
886
+ writeTool("Make glow map"),
887
+ tool(async ({ id, frameId: fid }) => {
888
+ const data = await apiPost("/generate/glow-map", { id, frameId: fid });
889
+ const keys = data.maps?.glows || (data.maps?.glow ? [data.maps.glow] : []);
890
+ const content = [
891
+ {
892
+ type: "text",
893
+ text: `Glow map${keys.length > 1 ? `s (${keys.length})` : ""} saved on asset ${id} (${APP_URL}/asset/${id}):\n` +
894
+ keys.map((k) => `${CDN_URL}/${k}`).join("\n"),
895
+ },
896
+ ];
897
+ const image = keys.length ? await inlineImage(keys[0]) : null;
898
+ if (image) content.push(image);
899
+ return { content, structuredContent: { summary: content[0].text } };
900
+ })
901
+ );
902
+
800
903
  server.tool(
801
904
  "rotate_sprite",
802
905
  "Turn a sprite into an 8-way set (front, back, sides and diagonals) in one pass. The result is a " +
@@ -929,7 +1032,8 @@ export function createVoidlessServer(API_KEY) {
929
1032
  server.tool(
930
1033
  "animate_sprite",
931
1034
  "Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
932
- "(walk + run + attack, or every direction) use generate_animation_set instead: it is one job. 15 credits.",
1035
+ "(walk + run + attack, or every direction) use generate_animation_set instead: one job, " +
1036
+ "15 credits per clip. 15 credits.",
933
1037
  {
934
1038
  id: assetId,
935
1039
  prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
@@ -950,22 +1054,24 @@ export function createVoidlessServer(API_KEY) {
950
1054
 
951
1055
  server.tool(
952
1056
  "generate_animation_set",
953
- "Generate several animations in one job. Two ways to use it:\n" +
954
- "1. A PRESET on one sprite: platformer-character (idle, run, jump, attack-1, death, hurt) or a top-down " +
955
- "set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Each item gets the preset's actions.\n" +
1057
+ "Generate several animations in one job, one clip per animation, all run in parallel. Ways to use it:\n" +
1058
+ "1. A PRESET on one or more sprites: platformer-character (idle, walk, run, jump, attack-1, death, hurt) or " +
1059
+ "a top-down set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Every sprite gets every " +
1060
+ "action of the preset.\n" +
956
1061
  "2. A PRESET across an 8-way rotation set: one item per facing, {assetId, frameIndex:<rotation frame id>, " +
957
1062
  "imageIndex:i} where i is the facing's position in the frame's facings list, and every facing gets its own " +
958
1063
  "directional clip (walk-left from the left-facing image, …). If the facings list has no 'down' (the sprite " +
959
1064
  "was drawn facing the camera), add {assetId, base:true} as 'down'. If it already has 'down' (a side-view " +
960
1065
  "sprite), use only the rotation's own images: the base is the side view, not the front. For topdown-idle " +
961
- "pick only the 4 cardinals (down, right, up, left).\n" +
962
- "Without a preset, list the actions in the prompt for up to 6 custom clips, one clip each: " +
963
- "'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: raise " +
964
- "the sword to guard'); for one image a plain 'idle, run, jump' also works. Every image gets every action. " +
965
- "Cost: 100 credits per pass (200 for 1024px sprites, which run in 4K); a pass holds up to 6 clips. On an " +
966
- "8-way rotation set the left, up-left and down-left clips are mirrored from the right-side ones (the " +
967
- "rotation's left side is a mirror already), so an 8-way set is ONE pass; set flipFacings false to animate " +
968
- "all eight (2 passes). Clips are added to their source asset as frames named after the action.",
1066
+ "pick only the 4 cardinals (down, right, up, left). The left, up-left and down-left clips are mirrored " +
1067
+ "from their right-side twins in the same rotation set, for free; set flipFacings false to animate them too.\n" +
1068
+ "3. No preset, a typed action: one clip per image. A list of actions makes one clip per action (up to 6): " +
1069
+ "'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: " +
1070
+ "raise the sword to guard'); for one image a plain 'idle, run, jump' also works. Several actions on several " +
1071
+ "images: every image gets every action.\n" +
1072
+ "Cost: 15 credits per clip (mirrored clips are free; clips that fail are refunded). Clips that come back to " +
1073
+ "their start (idle, walk, run, attack, jump, hurt, dash) end on their own first frame, so they loop; one-off " +
1074
+ "clips such as a death do not. Clips are added to their source asset as frames named after the action.",
969
1075
  {
970
1076
  items: z.array(pick).min(1).max(12).describe("Images to animate, in order"),
971
1077
  preset: z
@@ -973,8 +1079,8 @@ export function createVoidlessServer(API_KEY) {
973
1079
  .optional()
974
1080
  .describe("Named set of actions (see get_animation_presets)"),
975
1081
  prompt: z.string().default("").describe("Without a preset: the actions to make, as 'name: description' separated by semicolons. With one: optional extra direction for every clip"),
976
- holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, so directional loops do not drift"),
977
- flipFacings: z.boolean().default(true).describe("On an 8-way rotation set, mirror the left-side clips from the right-side ones instead of animating them (half the price). One-sided details swap sides, as on the rotation images"),
1082
+ holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, even one-off clips such as a death (by default loops end on their start and one-offs don't)"),
1083
+ flipFacings: z.boolean().default(true).describe("On an 8-way rotation set, mirror the left-side clips from the right-side ones instead of animating them (mirrored clips are free). One-sided details swap sides, as on the rotation images"),
978
1084
  autoFlip: z.boolean().default(false).describe("After the run, mirror every right-side clip on the asset that has no left twin (animate only the right-facing turns, get all eight). Free"),
979
1085
  },
980
1086
  writeTool("Generate animation set"),
@@ -1071,12 +1177,17 @@ export function createVoidlessServer(API_KEY) {
1071
1177
 
1072
1178
  server.tool(
1073
1179
  "generate_3d_model",
1074
- "Generate a 3D model (GLB) from a prompt. 15 credits (lq) or 60 (hq).",
1180
+ "Generate a 3D model (GLB) from a prompt. 45 credits (game-ready) or 15 (lq draft).",
1075
1181
  {
1076
1182
  prompt: z.string().describe("The model to make"),
1077
1183
  type: z.enum(["object", "character"]).default("object").describe("'character' makes a T-posed figure ready for rigging"),
1078
- style: z.string().default("cartoon style").describe("e.g. 'cartoon style', 'low-poly', 'realistic'"),
1079
- mode: z.enum(["lq", "hq"]).default("hq").describe("lq = fast draft (15 credits), hq = detailed (60 credits)"),
1184
+ style: z.string().default("low-poly").describe(
1185
+ "'low-poly' (default: faceted triangles, flat colours), 'blocky' (voxel art, built from cubes), 'claymation', 'origami', " +
1186
+ "'hand-painted' (stylized fantasy) or 'toy' (glossy vinyl figure)"
1187
+ ),
1188
+ mode: z.enum(["game", "lq", "hq"]).default("game").describe(
1189
+ "game = game-ready, a light clean mesh of about 19k triangles (45 credits); lq = draft (15 credits). hq is an old name for game"
1190
+ ),
1080
1191
  bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters only"),
1081
1192
  seed: z.number().int().min(0).default(0),
1082
1193
  },
@@ -1089,11 +1200,10 @@ export function createVoidlessServer(API_KEY) {
1089
1200
 
1090
1201
  server.tool(
1091
1202
  "rig_3d_model",
1092
- "Add a skeleton to a humanoid 3D model so it can be animated (non-humanoids are refused before any " +
1093
- "charge). Run before animate_3d_model. 140 credits.",
1203
+ "Add a skeleton to a 3D model so it can be animated: a humanoid, a four-legged animal, or a prop's " +
1204
+ "moving parts (a chest lid, a door, a tree's trunk). Run before animate_3d_model. 30 credits.",
1094
1205
  {
1095
1206
  id: z.number().int().describe("3D model asset ID"),
1096
- height_meters: z.number().min(0.2).max(10).default(1.7).describe("Character height in metres"),
1097
1207
  },
1098
1208
  writeTool("Rig 3D model"),
1099
1209
  tool(async (args) => {
@@ -1104,11 +1214,18 @@ export function createVoidlessServer(API_KEY) {
1104
1214
 
1105
1215
  server.tool(
1106
1216
  "animate_3d_model",
1107
- "Animate a rigged 3D model from a description of the motion. 30 credits.",
1217
+ "Animate a rigged 3D model. Either a ready-made move (free; characters: idle, walk, run, jump, attack, " +
1218
+ "punch, kick, wave, hurt, death, cast, dance; four-legged animals: idle, walk, run, jump, attack, sit, hurt, " +
1219
+ "death), a move copied from another of the user's rigged models (free: from_id + from_animation), or a " +
1220
+ "description of any motion ('wag its tail', 'open the lid', 20 credits). Returns the clip, a GLB of the " +
1221
+ "skeleton's motion that plays on the rigged model.",
1108
1222
  {
1109
1223
  id: z.number().int().describe("Rigged 3D model asset ID"),
1110
- prompt: z.string().describe("The motion, e.g. 'walk cycle', 'idle breathing'"),
1111
- duration: z.number().min(1).max(10).default(3).describe("Seconds"),
1224
+ preset: z.string().optional().describe("Ready-made move id, e.g. 'walk' (free)"),
1225
+ from_id: z.number().int().optional().describe("Copy a move from this other rigged model (free)"),
1226
+ from_animation: z.number().int().optional().describe("The animation id on from_id to copy"),
1227
+ prompt: z.string().optional().describe("Or describe the motion, e.g. 'a spinning kick' (20 credits)"),
1228
+ duration: z.number().min(1).max(10).optional().describe("Seconds, for a described motion (default: its natural length)"),
1112
1229
  },
1113
1230
  writeTool("Animate 3D model"),
1114
1231
  tool(async (args) => {