voidless-mcp 1.7.1 → 1.8.1

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 +8 -6
  2. package/package.json +2 -2
  3. package/tools.js +447 -52
package/README.md CHANGED
@@ -88,22 +88,24 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
88
88
 
89
89
  | Tool | What it does |
90
90
  | --- | --- |
91
- | `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
+ | `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. Always drawn in one of Voidless's style presets: with no `style`, one the prompt names ("a watercolour frost mage") is used, else pixel art below 512 and cartoon above; the result says which |
92
92
  | `edit_image` | Change an image with an instruction; the edit is added as a new frame |
93
93
  | `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 |
94
94
  | `make_glow_map` | The light an edit added (lit windows, a night version's lamps) on its own, for an additive glow layer; free |
95
- | `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass |
96
- | `extract_parts` | Split a character into body parts for rigging |
97
- | `upload_image` | Add an image you already have to your library |
95
+ | `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass (20 credits) |
96
+ | `extract_parts` | Cut a sheet of items into one new generated asset per item (default), at its own proportions, framed like a generated sprite, ready for rotate/animate/resize; or with `save_as: "frames"` split a character into body parts for rigging (layer frames) |
97
+ | `upload_image` | Add an image you already have to your library, from a public `url` or as base64 |
98
98
  | `pixelate_image` | Convert any image to pixel art (32 to 1024 px) |
99
99
  | `clean_up_sprite` | Make a sprite or a 1-5 s animation from anywhere game-ready: background removed, pixel art on a real grid, stray pixels fixed; videos come back as 16 frames |
100
+ | `resize_sprite` | Re-pixel a sprite generated in Voidless at any pixel size (32, 48, 64, 96, 128, 192, 256, 384 px), or make a pixel version of an HD image generated in Voidless, with its rotations and animations, as a new asset; free |
101
+ | `match_palette` | Put 1-12 sprites generated in Voidless on one shared palette (ramps built from them, your hex list, or one sprite's colours), one new asset each; free |
100
102
 
101
103
  **Animation**
102
104
 
103
105
  | Tool | What it does |
104
106
  | --- | --- |
105
- | `animate_sprite` | One 16-frame clip from a description of the motion |
106
- | `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 |
107
+ | `animate_sprite` | One 16-frame clip from a description of the motion. Big moves on a character that fills its frame get room (`room`: `auto`, `on`, `off`) |
108
+ | `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. `room` (`auto`, `on`, `off`) is decided once per sprite, so it stays one size in every clip |
107
109
  | `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 |
108
110
  | `flip_left_side` | Mirror the right-side animations into the left side (walk-right -> walk-left), free |
109
111
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "voidless-mcp",
3
3
  "mcpName": "dev.voidless/voidless",
4
- "version": "1.7.1",
4
+ "version": "1.8.1",
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>",
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "start": "node index.js",
42
42
  "start:http": "node http.js",
43
- "test": "cd free && npm test"
43
+ "test": "node --test test/ && cd free && npm test"
44
44
  },
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.12.1",
package/tools.js CHANGED
@@ -22,11 +22,109 @@ import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { randomUUID } from "node:crypto";
23
23
  import { Agent, setGlobalDispatcher } from "undici";
24
24
 
25
+ /**
26
+ * generate_image's style image as the API takes it. A key or URL goes as `styleFile` (the server checks
27
+ * it is on one of the caller's assets and loads it at full size) and `styleImage` is sent empty, as the
28
+ * API's `!== ""` checks expect; base64 stays in `styleImage`.
29
+ */
30
+ export function styleRef(styleImage, styleAssetId) {
31
+ const s = typeof styleImage === "string" ? styleImage.trim() : "";
32
+ const named = /^https?:\/\//i.test(s) || /^assets\//.test(s);
33
+ const out = { styleImage: named ? "" : s };
34
+ if (named) out.styleFile = s;
35
+ if (Number.isInteger(styleAssetId)) out.styleAssetId = styleAssetId;
36
+ return out;
37
+ }
38
+
25
39
  // Node's fetch gives up after 5 minutes without response headers. Generation calls answer only
26
40
  // when the job is done, and an 8-direction animation set can run longer, so wait as long as the
27
41
  // server does.
28
42
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
29
43
 
44
+ /** The longest generate_image prompt: 1000 for an asset sheet (artwork at 2048 / "4k"), else 300. */
45
+ export function promptLimit(type, size) {
46
+ return type === "artwork" && (size === 2048 || size === "4k" || size === 3840) ? 1000 : 300;
47
+ }
48
+
49
+ /**
50
+ * generate_asset_set / add_to_asset_set's text: the estimate first, then what was made, each piece's
51
+ * tags (what rotate_sprite and the animation tools read) and its view check, and the redraw offer.
52
+ * `appUrl` links the assets.
53
+ */
54
+ export function assetSetText(data, appUrl = "https://voidless.dev") {
55
+ const d = data || {};
56
+ const est = d.estimate || {};
57
+ const lines = [];
58
+ const priceLine = `Estimate: ${est.credits} credits = ${est.sheets} sheet(s) x ${est.sheetPrice} + ${est.cuts} cut(s) x ${est.cutPrice}.`;
59
+ if (d.estimate_only) {
60
+ lines.push(`Asset set plan (${d.view}${d.set_id ? `, adding to set ${d.set_id}` : ""}), nothing spent yet.`, priceLine);
61
+ for (const s of d.sheets || []) lines.push(` Sheet ${s.sheet}: ${s.kind}s, ${s.aspect} ${s.cols}x${s.rows}: ${s.items.join(", ")}`);
62
+ lines.push("Call again without estimate_only to make it.");
63
+ return lines.join("\n");
64
+ }
65
+ lines.push(`Asset set ${d.set_id} (${d.view}, style ${d.style}${d.style_words ? `, "${d.style_words}"` : ""}` +
66
+ `${d.style_asset_id ? `, styled from asset ${d.style_asset_id}` : ""}).`);
67
+ lines.push(priceLine + ` Charged: ${d.cost} credits.` + (typeof d.credits === "number" ? ` Credits left: ${d.credits}.` : ""));
68
+ if (d.tags) {
69
+ lines.push(`Pieces are tagged dimension "${d.tags.dimension}", characters facing "${d.tags.facing}"` +
70
+ (d.tags.rotate !== d.tags.dimension ? ` (rotations drawn in the "${d.tags.rotate}" camera)` : "") + ".");
71
+ }
72
+ for (const s of d.sheets || []) lines.push(`Sheet #${s.asset_id} (${s.aspect}): ${s.items.join(", ")} · ${appUrl}/asset/${s.asset_id}`);
73
+ const verdict = (p) => (p.ok === true ? `view OK (${p.check}${p.symmetry != null ? `, symmetry ${p.symmetry}` : ""})`
74
+ : p.ok === false ? `VIEW CHECK FAILED: ${p.reason}` : p.check === "none" ? "view not judged" : `view not checked`);
75
+ const world = (p) => (p.world_height ? ` · world height ${p.world_height}${p.world_height_source ? ` (${p.world_height_source})` : ""}` : "");
76
+ for (const p of d.pieces || []) {
77
+ lines.push(`#${p.asset_id} ${p.name} (${p.kind}${p.body ? `/${p.body}` : ""}, ${p.dimension}${p.facing ? `, facing ${p.facing}` : ""})` +
78
+ `${p.mirrored ? " mirrored to face right" : ""}${world(p)} · ${verdict(p)} · ${appUrl}/asset/${p.asset_id}`);
79
+ }
80
+ if ((d.pieces || []).some((p) => p.world_height)) {
81
+ lines.push(`World heights are in heights of ${d.reference && d.reference.name ? `"${d.reference.name}"` : "the reference character"} (= 1). ` +
82
+ "Size pieces in a game from them (and from export_godot_spriteframes' scale data), not from their image size.");
83
+ }
84
+ if ((d.missing || []).length) lines.push(`Not cut out: ${d.missing.join(", ")}.`);
85
+ for (const e of d.errors || []) lines.push(`Sheet ${e.sheet} ${e.step} failed (${e.items.join(", ")}): ${e.error}. Not charged.`);
86
+ if (d.redraw) {
87
+ lines.push(`To redraw ${d.redraw.items.join(", ")} in the same style (a small sheet for just those): ` +
88
+ `add_to_asset_set {set_id: ${d.set_id}, redraw_failed: true}, ${d.redraw.credits} credits. Ask the user first.`);
89
+ } else if ((d.pieces || []).length) {
90
+ lines.push("Every piece is in the set's view: rotate_sprite / generate_animation_set use these tags as they are.");
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+
95
+ /**
96
+ * get_asset's world-scale lines (docs/features/world-scale.md): the asset's height in world units and
97
+ * px per unit, and how to read the frames' sprite_scale. null when the asset has none yet.
98
+ */
99
+ export function worldText(meta = {}, frames = []) {
100
+ const scaled = (frames || []).filter((f) => Number(f.sprite_scale) > 0);
101
+ if (!(meta.world_height > 0) && !scaled.length) return null;
102
+ const bits = [];
103
+ if (meta.world_height > 0) {
104
+ bits.push(`World height: ${meta.world_height} units${meta.world_height_source ? ` (${meta.world_height_source}` +
105
+ `${meta.world_ref && meta.world_ref.name ? `, against "${meta.world_ref.name}"` : ""})` : ""}` +
106
+ (meta.px_per_unit > 0 ? ` · ${meta.px_per_unit} px per unit in the base image` : ""));
107
+ }
108
+ if (scaled.length) {
109
+ const values = [...new Set(scaled.map((f) => f.sprite_scale))];
110
+ bits.push(`Frames carry sprite_scale = the character's pixel scale in that frame against the base image ` +
111
+ `(${values.length > 4 ? `${Math.min(...values)}-${Math.max(...values)}` : values.join(", ")} here): px per unit there = ` +
112
+ `${meta.px_per_unit > 0 ? meta.px_per_unit : "px_per_unit"} x sprite_scale. For a game use export_godot_spriteframes ` +
113
+ `with asset_id (it evens the clips out), not one scale for every clip.`);
114
+ }
115
+ return bits.join("\n");
116
+ }
117
+
118
+ // The pixel-art sizes, as everywhere else in Voidless (strapi helpers/pixel PIXEL_SIZES). 48, 96, 192
119
+ // and 384 sit between the classic 32/64/128/256.
120
+ export const PIXEL_SIZES = [32, 48, 64, 96, 128, 192, 256, 384];
121
+ const PIXEL_SIZE_TEXT = "32, 48, 64, 96, 128, 192, 256 or 384";
122
+
123
+ /** A size field that takes exactly these values (numbers, plus any string literal such as "4k"). */
124
+ export function sizeSchema(...sizes) {
125
+ return z.union(sizes.map((s) => z.literal(s)));
126
+ }
127
+
30
128
  export const VERSION = "1.7.0";
31
129
  const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
32
130
  const CDN_URL = "https://cdn.voidless.dev";
@@ -49,7 +147,7 @@ const GALLERY_TOOLS = [
49
147
  "generate_image", "upload_image", "edit_image", "make_normal_map", "make_glow_map", "rotate_sprite", "animate_sprite", "generate_animation_set",
50
148
  "flip_left_side", "generate_video", "generate_sfx", "generate_music", "generate_3d_model", "get_asset",
51
149
  "list_assets", "extract_parts", "pixelate_image", "extend_video", "clean_up_sprite", "check_generation",
52
- "render_3d_sprites",
150
+ "render_3d_sprites", "resize_sprite", "match_palette", "generate_asset_set", "add_to_asset_set",
53
151
  ];
54
152
  const GALLERY_HTML = readFileSync(new URL("./gallery.html", import.meta.url), "utf8");
55
153
 
@@ -62,7 +160,9 @@ const LONG_PATHS = new Set([
62
160
  "/generate/image", "/generate/image-edit", "/generate/bbox-edit", "/generate/image-rotations", "/generate/image-parts",
63
161
  "/generate/image-animation", "/generate/multi-animation", "/generate/video", "/generate/video-extend",
64
162
  "/generate/sfx", "/generate/music", "/generate/3d-model", "/generate/model-rig", "/generate/model-animation",
65
- "/generate/model-sprites",
163
+ "/generate/model-sprites", "/generate/asset-set",
164
+ // Free, but a character with many clips takes minutes to resize.
165
+ "/generate/resize", "/generate/match-palette",
66
166
  ]);
67
167
  const WAIT_MS = Number(process.env.VOIDLESS_WAIT_MS) || 80_000;
68
168
  const CUT_OFF = /\((502|504|520|522|524)\)|fetch failed|ECONNRESET|socket|terminated|other side closed/i;
@@ -237,14 +337,16 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
237
337
 
238
338
  // One line per frame, carrying everything a follow-up call needs: the frame id (frameIndex /
239
339
  // items / frameId elsewhere) and the image keys (referenceFile / end_frame / imageIndex).
240
- function frameLine(frame) {
340
+ function frameLine(frame, { allKeys = false } = {}) {
241
341
  const images = Array.isArray(frame.images) ? frame.images : [];
242
342
  const bits = [`frame ${frame.id}`, frame.type || "frame", frame.name ? `"${frame.name}"` : null];
243
343
  if (frame.tags) bits.push(`${isRotation(frame) ? "facings" : "tags"}: ${frame.tags}`);
344
+ if (frame.size) bits.push(`${frame.size}px frames`);
345
+ if (Number(frame.sprite_scale) > 0) bits.push(`sprite_scale ${frame.sprite_scale}`);
244
346
  const imageText =
245
- images.length <= 8
347
+ images.length <= 8 || allKeys
246
348
  ? images.map((k, i) => ` [${i}] ${k}`).join("\n")
247
- : ` ${images.length} images, first ${images[0]}, last ${images[images.length - 1]}`;
349
+ : ` ${images.length} images, first ${images[0]}, last ${images[images.length - 1]} (get_asset with frameId lists them all)`;
248
350
  return ` - ${bits.filter(Boolean).join(" · ")}${images.length ? "\n" + imageText : ""}`;
249
351
  }
250
352
 
@@ -262,10 +364,31 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
262
364
  }
263
365
 
264
366
  // Responses that create a new asset: { file, assetId, credits, cost, … }.
265
- async function assetResult(data, label) {
367
+ // Which style preset an image was drawn in and why (the image route always picks exactly one).
368
+ const STYLE_SOURCES = {
369
+ explicit: "as asked",
370
+ mapped: "nearest preset to the style given",
371
+ prompt: "named in the prompt",
372
+ default: "default",
373
+ };
374
+ function styleLine(data) {
375
+ if (!data || !data.style || !STYLE_SOURCES[data.styleSource]) return null;
376
+ const why = data.styleUnknown
377
+ ? `default; no preset matches style "${data.styleUnknown}"`
378
+ : data.styleSkipped
379
+ ? `default; "${data.styleSkipped}" in the prompt needs size 512 or 1024`
380
+ : STYLE_SOURCES[data.styleSource];
381
+ const palette = data.palettePreset && data.palettePreset !== "none"
382
+ ? ` · palette ${data.palettePreset}${data.paletteSource === "prompt" ? " (named in the prompt)" : ""}`
383
+ : "";
384
+ return `Style preset: ${data.style} (${why})${palette}`;
385
+ }
386
+
387
+ async function assetResult(data, label, extra = []) {
266
388
  const url = cdnUrl(data.file);
267
389
  const lines = [
268
390
  `${label} created.`,
391
+ ...extra,
269
392
  data.assetId ? `Asset ID: ${data.assetId} · ${APP_URL}/asset/${data.assetId}` : null,
270
393
  url && !mimeFromPath(url).startsWith("image/") ? `File: ${url}` : null,
271
394
  data.thumbnail ? `Thumbnail: ${cdnUrl(data.thumbnail)}` : null,
@@ -327,9 +450,13 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
327
450
  content.push({ type: "text", text: `Asset ${result.assetId}: skipped${result.error ? ` (${result.error})` : ""}` });
328
451
  continue;
329
452
  }
453
+ // A clip that had to grow onto a bigger canvas: the character is the same pixel size, the frame bigger.
454
+ const grown = (result.grown || []).map((g) => `"${g.name}" came out on ${g.size}px frames (the sprite is ${g.sourceSize}px)`);
330
455
  content.push({
331
456
  type: "text",
332
- text: `Asset ${result.assetId} (${APP_URL}/asset/${result.assetId}):\n` + (result.frames || []).map(frameLine).join("\n"),
457
+ text: `Asset ${result.assetId} (${APP_URL}/asset/${result.assetId}):\n` + (result.frames || []).map(frameLine).join("\n") +
458
+ (grown.length ? `\n Bigger canvas: ${grown.join("; ")}. The character is the same pixel size in every clip; ` +
459
+ "line clips up by their feet anchor, don't scale them to one frame size." : ""),
333
460
  });
334
461
  for (const frame of result.frames || []) {
335
462
  if (inlined >= MAX_INLINE_IMAGES) break;
@@ -629,18 +756,37 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
629
756
  "get_asset",
630
757
  "Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
631
758
  "names, facings and image keys. Frame ids are what frameIndex/items/frameId take elsewhere; image keys " +
632
- "are what referenceFile and end_frame take, on this asset or any other of yours. Free.",
633
- { id: assetId, preview: z.boolean().default(true).describe("Include the base image inline") },
759
+ "are what referenceFile and end_frame take, on this asset or any other of yours. Long clips show their first " +
760
+ "and last key; pass frameId to list every key of that frame. Each key downloads at its exact pixels from " +
761
+ "https://cdn.voidless.dev/<key>. Also its world height (in heights of the hero / its set's reference character) " +
762
+ "and each frame's sprite_scale (how big the character is drawn there against the base: video clips are " +
763
+ "usually ~0.57): size it in a game from these, never by eye. Free.",
764
+ {
765
+ id: assetId,
766
+ frameId: z.number().int().optional().describe("Show only this frame, with every image key"),
767
+ preview: z.boolean().default(true).describe("Include the base image inline"),
768
+ },
634
769
  readTool("Get asset"),
635
- tool(async ({ id, preview }) => {
770
+ tool(async ({ id, frameId, preview }) => {
636
771
  const a = await apiGet(`/assets/${id}`);
637
772
  const meta = a.meta_data || {};
638
773
  const frames = (meta.frames || []).filter((f) => !f.archived);
774
+ if (frameId !== undefined) {
775
+ const frame = frames.find((f) => f.id === frameId);
776
+ if (!frame) throw new Error(`Asset ${id} has no frame ${frameId}. Use get_asset ${id} for its frame ids.`);
777
+ const content = [{ type: "text", text: `Asset ${a.id} · frame ${frame.id}:\n${frameLine(frame, { allKeys: true })}` }];
778
+ if (preview) {
779
+ const image = await inlineImage(stillOf(frame));
780
+ if (image) content.push(image);
781
+ }
782
+ return { content };
783
+ }
639
784
  const lines = [
640
785
  `Asset ${a.id} · ${a.type}${meta.image_type ? "/" + meta.image_type : ""} · ${APP_URL}/asset/${a.id}`,
641
786
  a.prompt ? `Prompt: ${a.prompt}` : null,
642
787
  `Tags: ${a.tags || "(none)"}`,
643
788
  meta.size ? `Size: ${meta.size}px · ${meta.dimension || ""} · style ${meta.style || "-"} · facing ${meta.facing || "-"}` : null,
789
+ worldText(meta, frames),
644
790
  a.file ? `Base image: ${a.file}` : null,
645
791
  meta.rigged_file ? `Rigged: yes (${(meta.animations || []).length} animations)` : null,
646
792
  Array.isArray(meta.video_extensions) && meta.video_extensions.length
@@ -669,11 +815,15 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
669
815
  const data = await apiGet("/generate/multi-animation/presets");
670
816
  const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
671
817
  const perClip = Number(data.pricePerClip) > 0 ? Number(data.pricePerClip) : 15;
818
+ const perHdClip = Number(data.pricePerHdClip) > 0 ? Number(data.pricePerHdClip) : 25;
819
+ const perHdRunClip = Number(data.pricePerHdRunClip) > 0 ? Number(data.pricePerHdRunClip) : 30;
672
820
  return {
673
821
  content: [
674
822
  {
675
823
  type: "text",
676
- text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip (mirrored left-side clips are free). ` +
824
+ text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip, ${perHdClip} for a 1024 HD image ` +
825
+ `(drawn at 1080P), ${perHdRunClip} for a looping run on a 1024 HD image (a 2 s clip); ` +
826
+ "mirrored left-side clips are free. " +
677
827
  `Up to ${data.maxImages} images per run.`,
678
828
  },
679
829
  ],
@@ -752,17 +902,29 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
752
902
  server.tool(
753
903
  "generate_image",
754
904
  "Generate a game sprite/asset image. Infer every field from the request: art STYLE, colour PALETTE, " +
755
- "asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Use the default model " +
756
- "(flux2-klein, 1 credit) unless the user names another. Costs by model: flux2-klein 1, flux-juggernaut 1, " +
757
- "seedream-5-flash 10, gpt-image-2.5 5, retro-diffusion 10, nano-banana-2 15 credits (a reference image makes it " +
758
- "2 and uses flux2-klein). Call it once per request and " +
759
- "show the result; ask the user before generating again (each call spends credits).",
905
+ "asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Leave model out unless the user names one: " +
906
+ "the server then picks by size (flux2-klein, 1 credit, up to 64 px; gpt-image-2.5, 3 credits, from 96 px). Costs by model: flux2-klein 1, flux-juggernaut 1, " +
907
+ "seedream-5-flash 10, gpt-image-2.5 3, gpt-image-2.6 10, retro-diffusion 10, nano-banana-2 15 credits (a reference image makes it " +
908
+ "2 and uses flux2-klein). ASSET SHEETS: type 'artwork' + model 'gpt-image-2.6' + size 2048 (20 credits) or '4k' " +
909
+ "(30 credits) draws one big picture holding a whole matching set (e.g. 'nine fantasy RPG items in a grid, clear space " +
910
+ "between them, plain light background') at full resolution; cut it into one asset per item with extract_parts " +
911
+ "save_as 'assets' (each cut-out a 1024 sprite). Call it once per request and " +
912
+ "show the result; ask the user before generating again (each call spends credits). Every image is drawn in " +
913
+ "exactly one of Voidless's fixed style presets, and the result says which one was used and why.",
760
914
  {
761
- prompt: z.string().max(300).describe("The subject only (max 300 chars). Put style/colour in the style/palette fields."),
915
+ prompt: z
916
+ .string()
917
+ .max(1000)
918
+ .describe(
919
+ "The subject (max 300 chars; an asset sheet, type 'artwork' at size 2048 or '4k', up to 1000 so each item " +
920
+ "and its view can be named). Prefer the style/palette fields; if style is empty, a style the prompt names " +
921
+ "(e.g. 'watercolour frost mage', 'a cel-shaded golem') picks that preset and those words are taken out of the " +
922
+ "prompt, so the preset's own style words are the only ones the model sees."
923
+ ),
762
924
  model: z
763
- .enum(["flux2-klein", "gpt-image-2.5", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
764
- .default("flux2-klein")
765
- .describe("The default, flux2-klein, unless the user asks for a specific model by name."),
925
+ .enum(["flux2-klein", "gpt-image-2.5", "gpt-image-2.6", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
926
+ .optional()
927
+ .describe("Only when the user names a model. Left out, the server picks by size: flux2-klein up to 64 px, gpt-image-2.5 from 96 px."),
766
928
  type: z
767
929
  .enum(["object", "character", "character portrait", "artwork", "tileset", "ui"])
768
930
  .describe(
@@ -771,22 +933,35 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
771
933
  "tilesetKind; below 512 it is drawn at 512 and pixelated); 'ui' = interface pieces (see uiMode)."
772
934
  ),
773
935
  dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
774
- size: z
775
- .union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)])
936
+ size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024, 2048, "4k")
776
937
  .default(512)
777
- .describe("Output px. 32/64/128/256 give a pixel-art look; 512 for every other style; 1024 for high-res."),
778
- colours: z.number().int().min(4).max(12).default(8).describe("Colour count for pixel-art reduction (4–12)"),
938
+ .describe(
939
+ "Output px. 32, 48, 64, 96, 128, 192, 256 and 384 give pixel art; 512 for every other style; 768 for a sharper smooth sprite (drawn at 1024, stored at 768); 1024 for high-res. 2048 and '4k' are " +
940
+ "ASSET-SHEET sizes, only for type 'artwork' with model 'gpt-image-2.6' in a smooth (not pixel) style: 2048 = " +
941
+ "2048x2048 (aspect '16:9': 2560x1440), '4k' = 2880x2880 (aspect '16:9': 3840x2160), kept at that size. For a " +
942
+ "set of matching assets to cut apart with extract_parts save_as 'assets'."
943
+ ),
779
944
  style: z
780
945
  .string()
781
946
  .default("")
782
947
  .describe(
783
948
  "Style preset: pixel-art, cartoon, cel-shading, flat, hand-drawn, paper-cutout, comic-book, anime, watercolor, " +
784
- "low-poly, 3d-render, claymation, photorealistic, voxel. Empty = pixel art below 512, cartoon otherwise."
949
+ "low-poly, 3d-render, claymation, photorealistic, voxel. Always ends up as one of these: another value is " +
950
+ "mapped to the nearest preset ('watercolour', '8-bit', 'realistic'), or the default if none fits. Empty = the " +
951
+ "preset the prompt names, at 512/1024 for non-pixel styles (at 32-384 the result is pixel art); otherwise " +
952
+ "pixel art below 512, cartoon at 512/1024."
785
953
  ),
786
954
  palette: z
787
- .enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia"])
955
+ .enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia", "nes", "pico8", "c64", "cga", "endesga32", "sunset", "ice", "dungeon", "forest", "desert", "candy", "cyberpunk"])
788
956
  .default("none")
789
- .describe("Colour-scheme preset, independent of style. 'none' keeps the style's own colours."),
957
+ .describe(
958
+ "Colour-scheme preset, independent of style. 'none' = the palette the prompt names (e.g. 'in a neon palette', " +
959
+ "'greyscale'), else the style's own colours. Pixel art (32-384 px, or a pixel style) is also held to the " +
960
+ "preset's colours: gameboy (the 4 DMG greens), cga (4), pico8 (16 fixed), c64 (16 fixed), nes (16 from the " +
961
+ "NES set), endesga32 (32 fixed), black-and-white (5 greys), sepia (8); retro, earthy, sunset, ice, dungeon, " +
962
+ "forest, desert, candy and cyberpunk set the mood and cap the colours (12-16). Edits, rotations and " +
963
+ "animations of the asset keep its palette. There is no colour-count setting: Voidless picks it by size."
964
+ ),
790
965
  facing: z.enum(["forward", "right"]).default("forward").describe("Characters: front view or side profile. Side profiles rotate best."),
791
966
  pose: z
792
967
  .enum(["idle", "tpose"])
@@ -804,29 +979,59 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
804
979
  "type 'tileset' only: 'platformer' = side-view 3x3 ground sheet (default for 2d); 'wang' = top-down two-terrain " +
805
980
  "4x5 auto-tile sheet in the free Tileset Generator's layout (default for top down; name both terrains, e.g. " +
806
981
  "'a cobblestone path in grass'), every edge matched so any arrangement tiles seamlessly; 'blob17' = top-down " +
807
- "17-piece sheet of one terrain on transparent. Top-down sheets have 256 px tiles; size 32/64/128 returns pixel tiles"
982
+ "17-piece sheet of one terrain on transparent. Top-down sheets have 256 px tiles; a pixel size returns pixel tiles at that size (up to 256)"
808
983
  ),
809
984
  aspect: z
810
985
  .enum(["1:1", "16:9"])
811
986
  .default("1:1")
812
- .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"),
987
+ .describe("type 'artwork' at size 512, 1024, 2048 or '4k' 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; 2560x1440 at 2048, 3840x2160 at '4k'). Smaller sizes, Retro Diffusion and everything that is not artwork are always 1:1"),
813
988
  uiMode: z
814
989
  .enum(["graphic", "title", "text"])
815
990
  .default("graphic")
816
991
  .describe("type 'ui' only: 'graphic' = button/panel/HUD piece, 'title' = game-title wordmark spelling the prompt, 'text' = plain label spelling the prompt"),
817
992
  seed: z.number().int().min(0).default(0).describe("RNG seed (0 = random)"),
818
993
  referenceImage: z.string().default("").describe("Optional raw base64 image (no data: prefix) to base the result on"),
819
- styleImage: z.string().default("").describe("Optional raw base64 image whose art style to copy"),
994
+ styleImage: z
995
+ .string()
996
+ .default("")
997
+ .describe(
998
+ "Optional image whose art style to copy: an image key from one of your assets (get_asset lists them, e.g. " +
999
+ "assets/64/images/<id>.png) or its cdn.voidless.dev URL. The server loads it at full resolution. Prefer " +
1000
+ "this (or styleAssetId) over base64: get_image_data only returns a small thumbnail. With gpt-image-2.6 " +
1001
+ "the picture itself is shown to the model; other models get a description of its style"
1002
+ ),
1003
+ styleAssetId: z
1004
+ .number()
1005
+ .int()
1006
+ .optional()
1007
+ .describe("Optional: the id of one of your image assets whose art style to copy (its main image, full resolution). Same as passing its image key as styleImage"),
820
1008
  },
821
1009
  writeTool("Generate image"),
822
- tool(async ({ facing, pose, bodyType, palette, uiMode, tilesetKind, aspect, ...rest }) => {
1010
+ tool(async ({ facing, pose, bodyType, palette, uiMode, tilesetKind, aspect, styleAssetId, ...rest }) => {
1011
+ const sheetSized = rest.size === 2048 || rest.size === "4k";
1012
+ if (String(rest.prompt || "").length > promptLimit(rest.type, rest.size)) {
1013
+ throw new Error(
1014
+ `prompt is ${String(rest.prompt).length} characters; the limit is 300 (1000 for an asset sheet: type 'artwork' ` +
1015
+ "at size 2048 or '4k'). For a whole set drawn in the game's camera view, use generate_asset_set."
1016
+ );
1017
+ }
1018
+ if (sheetSized) {
1019
+ if (rest.type !== "artwork" || rest.model !== "gpt-image-2.6") {
1020
+ throw new Error(
1021
+ `size ${rest.size} is an asset-sheet size: only for type 'artwork' with model 'gpt-image-2.6'. ` +
1022
+ "Use 512, 768 or 1024 otherwise."
1023
+ );
1024
+ }
1025
+ if (rest.size === "4k") rest.size = 3840;
1026
+ }
823
1027
  const wide = rest.type === "artwork" && aspect === "16:9" ? "16:9" : "1:1";
824
1028
  const tileset = rest.type === "tileset";
825
1029
  // Tilesets are GPT-Image only (the server enforces it too); the kind rides in options.mode.
826
1030
  if (tileset) rest.model = "gpt-image-2.5";
827
1031
  const mode = tileset ? tilesetKind || "" : uiMode;
828
- const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode, aspect: wide } };
829
- return assetResult(await apiPost("/generate/image", body), "Image");
1032
+ const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode, aspect: wide }, ...styleRef(rest.styleImage, styleAssetId) };
1033
+ const data = await apiPost("/generate/image", body);
1034
+ return assetResult(data, "Image", [styleLine(data)]);
830
1035
  })
831
1036
  );
832
1037
 
@@ -910,7 +1115,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
910
1115
  "rotate_sprite",
911
1116
  "Turn a sprite into an 8-way set (front, back, sides and diagonals) in one pass. The result is a " +
912
1117
  "'rotations - 8-way' frame whose facings line up with its images; pass those to generate_animation_set " +
913
- "to animate each direction. Works best from a side-profile sprite. 10 credits.",
1118
+ "to animate each direction. Works best from a side-profile sprite. 20 credits.",
914
1119
  { id: assetId, referenceFile, frameId },
915
1120
  writeTool("Rotate sprite (8-way)"),
916
1121
  tool(async (args) => framesResult(await apiPost("/generate/image-rotations", args), args.id, "8-way rotation"))
@@ -918,17 +1123,44 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
918
1123
 
919
1124
  server.tool(
920
1125
  "extract_parts",
921
- "Split a character sprite into separate body parts (head, torso, arms, legs…) as 'layer' frames, for " +
922
- "rigging or cut-out animation. 10 credits. Call get_asset afterwards for the parts' frame ids.",
1126
+ "Cut an image into its separate pieces. Two uses: (1) a sheet of game items (characters, props, trees...) " +
1127
+ "into one NEW asset per item (the default, save_as 'assets'): each item at its own proportions, framed on " +
1128
+ "a square at the sheet's size (1024 for a 2048/4k asset sheet from generate_image, cut from the " +
1129
+ "full-resolution sheet) " +
1130
+ "like a generated sprite (filling the frame), named after the item, a character or an object, in the " +
1131
+ "sheet's projects and style. " +
1132
+ "Those are generated images like any other: rotate_sprite, animate_sprite, generate_animation_set and " +
1133
+ "resize_sprite all take them; (2) a character sprite into body parts (head, torso, arms, legs...) as " +
1134
+ "'layer' frames on that asset, for rigging or cut-out animation: pass save_as 'frames'. 10 credits either " +
1135
+ "way. Frames: call get_asset afterwards for their ids.",
923
1136
  {
924
1137
  id: assetId,
925
- prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = the standard rig parts"),
1138
+ prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = every separate item (assets) or the standard rig parts (frames)"),
1139
+ save_as: z.enum(["assets", "frames"]).default("assets")
1140
+ .describe("assets = each piece saved as its own new asset (default); frames = body-part layer frames on this asset, for rigging"),
1141
+ asset_type: z.enum(["auto", "character", "object"]).default("auto")
1142
+ .describe("save_as 'assets' only: the new assets' type. auto = character when the item's name is one (hero, golem, slime, wolf...), else object"),
926
1143
  referenceFile,
927
1144
  frameId,
928
1145
  },
929
1146
  writeTool("Extract sprite parts"),
930
1147
  tool(async (args) => {
931
1148
  const data = await apiPost("/generate/image-parts", args);
1149
+ if (args.save_as === "assets") {
1150
+ const assets = Array.isArray(data.assets) ? data.assets : [];
1151
+ const lines = [
1152
+ `Saved ${assets.length} item${assets.length === 1 ? "" : "s"} from asset ${args.id} as new assets:`,
1153
+ ...assets.map((a) => `#${a.id} ${a.prompt} (${a.image_type}) · ${APP_URL}/asset/${a.id}`),
1154
+ money(data),
1155
+ ].filter(Boolean);
1156
+ const content = [{ type: "text", text: lines.join("\n") }];
1157
+ for (const a of assets.slice(0, MAX_INLINE_IMAGES)) {
1158
+ const image = await inlineImage(a.file);
1159
+ if (image) content.push(image);
1160
+ }
1161
+ const items = assets.map((a) => mediaItem(a.file, `#${a.id} ${a.prompt || ""}`.trim(), { link: `${APP_URL}/asset/${a.id}` }));
1162
+ return withGallery({ content }, items, "Items");
1163
+ }
932
1164
  // The ids returned here are off by (parts - 1): the server numbers from the last appended
933
1165
  // frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
934
1166
  const names = (data.frames || []).map((f) => f.name).filter(Boolean);
@@ -943,31 +1175,104 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
943
1175
  })
944
1176
  );
945
1177
 
1178
+ // Asset sets (docs/features/asset-sets.md): sheets drawn in the game's camera view, cut, tagged and checked.
1179
+ const assetSetItem = z.object({
1180
+ name: z.string().min(1).max(80).describe("What it is, e.g. 'knight', 'ice golem', 'treasure chest'"),
1181
+ kind: z.enum(["character", "object", "tile"]).optional()
1182
+ .describe("character (rotated and animated), object (a prop) or tile. Omitted: guessed from the name"),
1183
+ body: z.enum(["humanoid", "quadruped", "blob", "flying"]).optional()
1184
+ .describe("Characters: body plan (a wolf is quadruped, a slime blob, a bat flying). Omitted: guessed"),
1185
+ notes: z.string().max(240).optional().describe("Optional details: colours, outfit, what it holds"),
1186
+ shape: z.enum(["tall", "wide", "square"]).optional().describe("Optional: how it fits a cell (a tree is tall, a cart wide). Omitted: guessed"),
1187
+ size: z.number().min(0.05).max(50).optional()
1188
+ .describe("Its height in the game world, as a multiple of the set's reference character (the hero = 1): a boss golem 2.5, a slime 0.5, a door 1.2. Wins over the height the sheet draws (sheets often draw a boss barely bigger than the hero). Give it whenever relative size matters"),
1189
+ reference: z.boolean().optional().describe("This item is the set's unit of height (default: the first humanoid character)"),
1190
+ });
1191
+ async function assetSetResult(data, label) {
1192
+ const content = [{ type: "text", text: assetSetText(data, APP_URL) }];
1193
+ for (const p of (data.pieces || []).slice(0, MAX_INLINE_IMAGES)) {
1194
+ const image = await inlineImage(p.file);
1195
+ if (image) content.push(image);
1196
+ }
1197
+ const items = [
1198
+ ...(data.pieces || []).map((p) => mediaItem(p.file, `#${p.asset_id} ${p.name}${p.ok === false ? " (view check failed)" : ""}`, { link: `${APP_URL}/asset/${p.asset_id}` })),
1199
+ ...(data.sheets || []).map((s) => mediaItem(s.file, `Sheet #${s.asset_id}`, { link: `${APP_URL}/asset/${s.asset_id}` })),
1200
+ ];
1201
+ return withGallery({ content }, items, label);
1202
+ }
1203
+
1204
+ server.tool(
1205
+ "generate_asset_set",
1206
+ "Make a whole matching game art set in the game's CAMERA VIEW: characters, props and tiles drawn on 2048 " +
1207
+ "GPT-Image 2.6 sheets, cut into one asset per item, each tagged with the view's dimension and facing so " +
1208
+ "rotate_sprite and the animation tools use the right angle. view: 'platformer' (side profile facing right, eye " +
1209
+ "level), 'topdown' (classic RPG, ~45° camera, characters head-on and symmetrical), 'topdown-overhead' (straight " +
1210
+ "down), 'isometric'. Items are planned onto sheets (about 3-4 characters, 4-6 objects or 3 tall props a sheet; " +
1211
+ "the first sheet, or style_asset_id, is the style every later sheet copies at full resolution). Cost: 20 a " +
1212
+ "sheet + 10 a cut; pass estimate_only to see the plan and price first. Every piece gets a free view check " +
1213
+ "(head-on for top-down, a profile for platformer) and failures are reported with a redraw offer; nothing else " +
1214
+ "is spent (no rotations or animations). Voidless+ only. Returns set_id: add_to_asset_set adds to it later. " +
1215
+ "Every piece gets a world_height in heights of the set's reference character (the first humanoid, or the item " +
1216
+ "marked reference), measured on the sheet before the cut-outs are reframed, or the item's own `size` when given. " +
1217
+ "In a game, size every piece from world_height and the export's scale data (export_godot_spriteframes with " +
1218
+ "asset_id), never by guessing from the image size: all cut-outs are framed to the same pixel height.",
1219
+ {
1220
+ view: z.enum(["platformer", "topdown", "topdown-overhead", "isometric"]).describe("The game's camera view"),
1221
+ items: z.array(assetSetItem).min(1).max(24).describe("Everything the set needs, e.g. [{name:'knight'}, {name:'frost wolf', body:'quadruped'}]"),
1222
+ style: z.string().default("cartoon").describe("Style preset (smooth styles: cartoon, cel-shading, flat, hand-drawn, anime, watercolor, comic-book, 3d-render, ...). Not pixel art: resize_sprite the pieces afterwards"),
1223
+ style_words: z.string().max(300).optional().describe("Optional extra style words for every sheet, e.g. 'muted colours, matte shading, medium outlines'"),
1224
+ palette: z.enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia", "nes", "pico8", "c64", "cga", "endesga32", "sunset", "ice", "dungeon", "forest", "desert", "candy", "cyberpunk"]).default("none"),
1225
+ style_asset_id: z.number().int().optional().describe("Optional: an existing sheet (or any image asset) whose style the whole set copies, at full resolution"),
1226
+ estimate_only: z.boolean().default(false).describe("true = only the sheet plan and the price; nothing is drawn or charged"),
1227
+ },
1228
+ writeTool("Generate asset set"),
1229
+ tool(async (args) => assetSetResult(await apiPost("/generate/asset-set", args), "Asset set"))
1230
+ );
1231
+
1232
+ server.tool(
1233
+ "add_to_asset_set",
1234
+ "Add items to an existing asset set (from generate_asset_set), reusing everything it was made with: its view, " +
1235
+ "style preset, style words, palette and style sheet. Or redraw_failed: true draws again, on a small sheet in " +
1236
+ "the same style, just the pieces that failed their view check or were not cut out. Same price: 20 a sheet + 10 " +
1237
+ "a cut; estimate_only shows it first. New pieces get world_height in the same reference character's heights " +
1238
+ "(give `size` for each new item whose size matters: a new sheet has no reference character on it to measure against).",
1239
+ {
1240
+ set_id: z.number().int().describe("The set's id (generate_asset_set's set_id: its first sheet's asset id)"),
1241
+ items: z.array(assetSetItem).max(24).default([]).describe("New items for the set"),
1242
+ redraw_failed: z.boolean().default(false).describe("Also redraw the set's pieces that failed the view check or went missing"),
1243
+ estimate_only: z.boolean().default(false).describe("true = only the plan and the price; nothing is drawn or charged"),
1244
+ },
1245
+ writeTool("Add to asset set"),
1246
+ tool(async (args) => assetSetResult(await apiPost("/generate/asset-set", args), "Asset set"))
1247
+ );
1248
+
946
1249
  server.tool(
947
1250
  "upload_image",
948
1251
  "Add an image the user already has to their library, so it can be animated, rotated or edited. It must " +
949
- "be square at 32, 64, 128, 256, 512 or 1024 px. Free.",
1252
+ `be square at ${PIXEL_SIZE_TEXT}, 512, 768 or 1024 px. Give it as a public URL (best: a long base64 string is ` +
1253
+ "easy to garble) or as base64. Free.",
950
1254
  {
951
- image: z.string().describe("Base64 image (a data: prefix is stripped automatically)"),
1255
+ url: z.string().optional().describe("Public http(s) link to the image. Use this when the image is online"),
1256
+ image: z.string().optional().describe("Base64 image instead of url (a data: prefix is stripped automatically)"),
952
1257
  type: z.enum(["object", "character", "character portrait", "artwork"]).default("character"),
953
1258
  remove_bg: z.boolean().default(false).describe("Remove the background on upload"),
954
1259
  },
955
1260
  writeTool("Upload image"),
956
- tool(async ({ image, ...rest }) => {
957
- const raw = String(image).replace(/^data:[^,]*,/, "");
958
- return assetResult(await apiPost("/upload/image", { image: raw, ...rest }), "Upload");
1261
+ tool(async ({ image, url, ...rest }) => {
1262
+ if (!url === !image) throw new Error(url ? "Give the image as a url or as base64 in image, not both." : "Give the image as a url or as base64 in image.");
1263
+ const body = url ? { url, ...rest } : { image: String(image).replace(/^data:[^,]*,/, ""), ...rest };
1264
+ return assetResult(await apiPost("/upload/image", body), "Upload");
959
1265
  })
960
1266
  );
961
1267
 
962
1268
  server.tool(
963
1269
  "pixelate_image",
964
- "Convert any image to pixel art at a target size and colour count. Returns the image; nothing is " +
1270
+ "Convert any image to pixel art at a target size (the colour count follows the size). Returns the image; nothing is " +
965
1271
  "saved to the library. 1 credit.",
966
1272
  {
967
1273
  image: z.string().describe("Base64 source image (a data: prefix is stripped automatically)"),
968
- size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)]).default(64)
969
- .describe("Pixel size. 512 and 1024 are drawn on a real 4x grid (128 / 256 art pixels)"),
970
- colours: z.number().int().min(4).max(12).default(8),
1274
+ size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024).default(64)
1275
+ .describe(`Pixel size: ${PIXEL_SIZE_TEXT}, or 512 and 1024, drawn on a real 4x grid (128 / 256 art pixels)`),
971
1276
  },
972
1277
  writeTool("Pixelate image"),
973
1278
  tool(async ({ image, ...rest }) => {
@@ -994,9 +1299,9 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
994
1299
  url: z.string().optional().describe("Public http(s) link to the image or video. Use this for videos"),
995
1300
  file: z.string().optional().describe("Base64 file instead of url (a data: prefix is stripped automatically)"),
996
1301
  kind: z.enum(["image", "video"]).optional().describe("What the file is. Inferred from the url's extension when omitted"),
997
- style: z.enum(["pixel", "smooth"]).default("pixel").describe("pixel = pixel art at 32-1024; smooth = clean cut-out at 512 or 1024"),
998
- size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)]).optional()
999
- .describe("Output size. Default 64 for pixel, 512 for smooth (smooth allows only 512 or 1024)"),
1302
+ style: z.enum(["pixel", "smooth"]).default("pixel").describe("pixel = pixel art at 32-1024; smooth = clean cut-out at 512, 768 or 1024"),
1303
+ size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024).optional()
1304
+ .describe(`Output size. Pixel: ${PIXEL_SIZE_TEXT}, 512 or 1024 (default 64); smooth: 512 (default), 768 or 1024`),
1000
1305
  motion: z.enum(["loop", "once"]).default("loop").describe("Video only: loop = find the cleanest loop; once = keep the whole clip"),
1001
1306
  },
1002
1307
  writeTool("Clean up sprite"),
@@ -1004,7 +1309,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1004
1309
  if (!url && !file) throw new Error("Give the file as a url or as base64 in file.");
1005
1310
  const pixel = style !== "smooth";
1006
1311
  const out = size || (pixel ? 64 : 512);
1007
- if (!pixel && out !== 512 && out !== 1024) throw new Error("Smooth art comes back at 512 or 1024.");
1312
+ if (!pixel && ![512, 768, 1024].includes(out)) throw new Error("Smooth art comes back at 512, 768 or 1024.");
1008
1313
  const guessed = /\.(mp4|webm|mov|m4v)(\?|$)/i.test(String(url || "")) ? "video"
1009
1314
  : /^data:video\//i.test(String(file || "")) ? "video" : "image";
1010
1315
  const body = { kind: kind || guessed, size: out, pixel, loop: motion };
@@ -1035,11 +1340,21 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1035
1340
  // Animation
1036
1341
  // ---------------------------------------------------------------------------
1037
1342
 
1343
+ // Room for big moves (image-api fit to room): a character that fills its frame is shrunk a little,
1344
+ // inside the same frame, so kicks and falls don't run off the edge.
1345
+ const room = z.enum(["auto", "on", "off"]).default("auto").describe(
1346
+ "Room for big moves. auto: when the motion is big (a kick, a special, a fall, a jump) and the character " +
1347
+ "fills its frame, it is drawn a little smaller in the same frame so nothing is cut off. on: always do " +
1348
+ "that. off: never. Making one character's moves over several calls, use on for every call so the " +
1349
+ "character stays one size"
1350
+ );
1351
+
1038
1352
  server.tool(
1039
1353
  "animate_sprite",
1040
1354
  "Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
1041
1355
  "(walk + run + attack, or every direction) use generate_animation_set instead: one job, " +
1042
- "15 credits per clip. 15 credits.",
1356
+ "15 credits per clip. 15 credits; 25 for a 1024 HD image (drawn at 1080P), 30 for a looping run on a " +
1357
+ "1024 HD image (run, jog, sprint, dash or a charge forward, ending on its start frame: a 2 s clip, played at its own pace).",
1043
1358
  {
1044
1359
  id: assetId,
1045
1360
  prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
@@ -1050,6 +1365,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1050
1365
  "image of another size is scaled to the start image's; one of a very different shape is refused (nothing charged)"
1051
1366
  ),
1052
1367
  frameId,
1368
+ room,
1053
1369
  },
1054
1370
  writeTool("Animate sprite"),
1055
1371
  tool(async (args) => framesResult(await apiPost("/generate/image-animation", { ...args, mode: "f" }), args.id, "Animation"))
@@ -1079,7 +1395,9 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1079
1395
  "'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: " +
1080
1396
  "raise the sword to guard'); for one image a plain 'idle, run, jump' also works. Several actions on several " +
1081
1397
  "images: every image gets every action.\n" +
1082
- "Cost: 15 credits per clip (mirrored clips are free; clips that fail are refunded). Clips that come back to " +
1398
+ "Cost: 15 credits per clip, 25 per clip of a 1024 HD image (drawn at 1080P), 30 per looping run clip " +
1399
+ "(run, jog, sprint, dash, a charge forward) of a 1024 HD image; mirrored clips are free and " +
1400
+ "clips that fail are refunded. Clips that come back to " +
1083
1401
  "their start (idle, walk, run, attack, jump, hurt, dash) end on their own first frame, so they loop; one-off " +
1084
1402
  "clips such as a death do not. Clips are added to their source asset as frames named after the action.",
1085
1403
  {
@@ -1092,6 +1410,9 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1092
1410
  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)"),
1093
1411
  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"),
1094
1412
  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"),
1413
+ room: room.describe("Room for big moves, decided once per sprite for the whole set so it is one size in every clip. " +
1414
+ "auto: if any clip is a big move (kick, special, fall, jump) and the character fills its frame, every clip of " +
1415
+ "that sprite draws it a little smaller. on: always. off: never"),
1095
1416
  },
1096
1417
  writeTool("Generate animation set"),
1097
1418
  tool(async (args) => resultsContent(await apiPost("/generate/multi-animation", args), "Animation set generated."))
@@ -1107,6 +1428,80 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
1107
1428
  tool(async ({ id }) => framesResult(await apiPost("/generate/flip", { id }), id, "Flip"))
1108
1429
  );
1109
1430
 
1431
+ server.tool(
1432
+ "resize_sprite",
1433
+ `Free. Only for images generated in Voidless (not uploads): pixel sprites of size ${PIXEL_SIZE_TEXT}, or HD ` +
1434
+ "(non-pixel, square 512/1024) images. Re-runs Voidless's pixel pass at a new size from the " +
1435
+ "art it stored, so the base image, its rotations, edits and every animation come out at the new size on one " +
1436
+ "palette and the character keeps its design (unlike generating it again smaller). On an HD image it makes a " +
1437
+ "pixel version at `size`: generate HD, then resize to 64 and to 32 for pixel versions. Makes a NEW asset; the " +
1438
+ "original is untouched.",
1439
+ {
1440
+ id: assetId,
1441
+ size: sizeSchema(...PIXEL_SIZES).optional()
1442
+ .describe(`New size in px: ${PIXEL_SIZE_TEXT}. Omit to keep the size (required for an HD image)`),
1443
+ },
1444
+ writeTool("Resize sprite"),
1445
+ tool(async ({ id, size }) => {
1446
+ const data = await apiPost("/generate/resize", { assetId: id, size });
1447
+ const asset = data.asset || {};
1448
+ const md = asset.meta_data || {};
1449
+ const link = `${APP_URL}/asset/${asset.id}`;
1450
+ const skipped = Array.isArray(data.skipped) ? data.skipped : [];
1451
+ const lines = [
1452
+ `Resized asset ${id} into a new asset: ${md.size} px, ${md.colours} colours. The original is unchanged. Free.`,
1453
+ `Asset ID: ${asset.id} · ${link}`,
1454
+ Array.isArray(data.palette) && data.palette.length ? `Palette: ${data.palette.join(" ")}` : null,
1455
+ skipped.length ? `${skipped.length} frame set(s) were left out: ` +
1456
+ skipped.map((x) => (x && typeof x === "object" ? `${x.name || `frame ${x.id}`} (${x.reason || "no stored renders"})` : `frame ${x}`)).join("; ") +
1457
+ "." : null,
1458
+ ].filter(Boolean);
1459
+ const content = [{ type: "text", text: lines.join("\n") }];
1460
+ const frames = (md.frames || []).filter((f) => !f.archived);
1461
+ if (frames.length) content.push({ type: "text", text: "Frames:\n" + frames.map(frameLine).join("\n") });
1462
+ const image = await inlineImage(asset.file);
1463
+ if (image) content.push(image);
1464
+ const items = [mediaItem(asset.file, "Resized", { link }), ...frames.flatMap((f) => frameItems(f, asset.id))];
1465
+ return withGallery({ content }, items, "Resized");
1466
+ })
1467
+ );
1468
+
1469
+ server.tool(
1470
+ "match_palette",
1471
+ `Free. Only for images generated in Voidless (not uploads): pixel sprites of size ${PIXEL_SIZE_TEXT} (an HD ` +
1472
+ "image: resize_sprite it to a pixel size first). Puts 1-12 sprites on ONE shared palette so separately generated assets look like one scene. The palette is " +
1473
+ "built from all of them as colour ramps, or given as hex colours, or led by one sprite's own colours; every " +
1474
+ "frame of every sprite is remapped along the ramps so shading stays smooth. Each sprite gets a NEW asset " +
1475
+ "(originals untouched).",
1476
+ {
1477
+ ids: z.array(z.number().int()).min(1).max(12).describe("The image assets to put on one palette (1-12)"),
1478
+ colours: z.number().int().min(8).max(64).optional()
1479
+ .describe("Size of the SHARED palette all of them draw from (8-64, default 24). Each sprite keeps roughly its own colour count"),
1480
+ palette: z.array(z.string().regex(/^#?[0-9a-fA-F]{6}$/)).min(1).max(64).optional()
1481
+ .describe("Use this palette instead, as hex colours like \"#1a2b3c\""),
1482
+ lead: z.number().int().optional().describe("One of ids whose own colours are kept exactly; the others follow it"),
1483
+ },
1484
+ writeTool("Match palette"),
1485
+ tool(async ({ ids, colours, palette, lead }) => {
1486
+ const data = await apiPost("/generate/match-palette", { assetIds: ids, colours, palette, leadAssetId: lead });
1487
+ const assets = Array.isArray(data.assets) ? data.assets : [];
1488
+ const shared = Array.isArray(data.palette) ? data.palette : [];
1489
+ const lines = [
1490
+ `Matched ${assets.length} sprite${assets.length === 1 ? "" : "s"} on one ${shared.length}-colour palette. Originals unchanged. Free.`,
1491
+ ...assets.map((a) => `Asset ${a.meta_data?.derived_from?.assetId} -> new asset ${a.id} · ${APP_URL}/asset/${a.id}`),
1492
+ `Palette: ${shared.join(" ")}`,
1493
+ Array.isArray(data.skipped) && data.skipped.length ? `Not matched: ${data.skipped.join(", ")}` : null,
1494
+ ].filter(Boolean);
1495
+ const content = [{ type: "text", text: lines.join("\n") }];
1496
+ for (const a of assets.slice(0, MAX_INLINE_IMAGES)) {
1497
+ const image = await inlineImage(a.file);
1498
+ if (image) content.push(image);
1499
+ }
1500
+ const items = assets.map((a) => mediaItem(a.file, `#${a.id}`, { link: `${APP_URL}/asset/${a.id}` }));
1501
+ return withGallery({ content }, items, "Palette matched");
1502
+ })
1503
+ );
1504
+
1110
1505
  // ---------------------------------------------------------------------------
1111
1506
  // Audio
1112
1507
  // ---------------------------------------------------------------------------