voidless-mcp 1.7.0 → 1.8.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/README.md +12 -8
- package/package.json +2 -2
- package/tools.js +453 -57
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` |
|
|
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
|
|
|
@@ -118,7 +120,9 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
|
|
|
118
120
|
|
|
119
121
|
**Free game-dev tools** (no credits)
|
|
120
122
|
|
|
121
|
-
The same tools as [voidless.dev/tools](https://voidless.dev/tools), for any image, not just your library. Inputs are image URLs (a `cdn.voidless.dev` link from `get_asset` works) or base64/data URIs, PNG or GIF, up to 2048x2048, 256 frames and 32 MB per call. Results come back in the chat: images as PNG/GIF, the `.tres` and atlas `.json` as text.
|
|
123
|
+
The same tools as [voidless.dev/tools](https://voidless.dev/tools), for any image, not just your library. Inputs are image URLs (a `cdn.voidless.dev` link from `get_asset` or a saved result works), image keys from your library (`assets/…png`, as `get_asset` lists them), or base64/data URIs, PNG or GIF, up to 2048x2048, 256 frames and 32 MB per call. Results come back in the chat: images as PNG/GIF, the `.tres` and atlas `.json` as text.
|
|
124
|
+
|
|
125
|
+
Results can also be saved to your library (free, private, counts toward your storage), which gives them an asset id and `cdn.voidless.dev` URLs to pass to the next tool instead of base64 (for example `generate_vfx` → `palette_swap` → `add_outline` → `animate_sprite`). `generate_vfx` saves its effect by default (with its recipe, so it can be rendered again; `save: false` skips it); `palette_swap`, `add_outline`, `pack_sprite_sheet`, `slice_sprite_sheet` and `make_gif` save only when you pass `save: true`. They run on Voidless's server: the hosted connector serves them, and the npm package forwards them there with your key (so they need a connection).
|
|
122
126
|
|
|
123
127
|
| Tool | What it does |
|
|
124
128
|
| --- | --- |
|
|
@@ -142,7 +146,7 @@ Generation tools create new content in your library; none deletes or overwrites
|
|
|
142
146
|
|
|
143
147
|
## Privacy Policy
|
|
144
148
|
|
|
145
|
-
This server sends your prompts, any images you pass in, and your API key to the Voidless API (api.voidless.dev) to run generations; results are stored in your Voidless account. The free tools send their inputs to mcp.voidless.dev, which processes them in memory and stores nothing; each call is logged against your account (tool, time taken, success, result size) for usage statistics. It collects nothing else and keeps no local data. See the [Voidless Privacy Policy](https://voidless.dev/privacy-policy) for how Voidless collects, uses, stores, shares and retains data, and the [Terms of Service](https://voidless.dev/terms-of-service).
|
|
149
|
+
This server sends your prompts, any images you pass in, and your API key to the Voidless API (api.voidless.dev) to run generations; results are stored in your Voidless account. The free tools send their inputs to mcp.voidless.dev, which processes them in memory and stores nothing unless a result is saved to your library (`generate_vfx` by default, the others with `save: true`), where it is kept like any other asset of yours; each call is logged against your account (tool, time taken, success, result size) for usage statistics. It collects nothing else and keeps no local data. See the [Voidless Privacy Policy](https://voidless.dev/privacy-policy) for how Voidless collects, uses, stores, shares and retains data, and the [Terms of Service](https://voidless.dev/terms-of-service).
|
|
146
150
|
|
|
147
151
|
## Support
|
|
148
152
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voidless-mcp",
|
|
3
3
|
"mcpName": "dev.voidless/voidless",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.8.0",
|
|
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
|
-
|
|
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,
|
|
@@ -286,7 +409,8 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
286
409
|
const content = [
|
|
287
410
|
{
|
|
288
411
|
type: "text",
|
|
289
|
-
|
|
412
|
+
// note: e.g. an end frame of another size was scaled to the start image's.
|
|
413
|
+
text: [`${label} added to asset ${assetId} (${APP_URL}/asset/${assetId}).`, data.note, money(data)]
|
|
290
414
|
.filter(Boolean)
|
|
291
415
|
.join("\n"),
|
|
292
416
|
},
|
|
@@ -326,9 +450,13 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
326
450
|
content.push({ type: "text", text: `Asset ${result.assetId}: skipped${result.error ? ` (${result.error})` : ""}` });
|
|
327
451
|
continue;
|
|
328
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)`);
|
|
329
455
|
content.push({
|
|
330
456
|
type: "text",
|
|
331
|
-
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." : ""),
|
|
332
460
|
});
|
|
333
461
|
for (const frame of result.frames || []) {
|
|
334
462
|
if (inlined >= MAX_INLINE_IMAGES) break;
|
|
@@ -425,8 +553,8 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
425
553
|
.string()
|
|
426
554
|
.default("")
|
|
427
555
|
.describe(
|
|
428
|
-
"Work from one image
|
|
429
|
-
"
|
|
556
|
+
"Work from one image instead of the asset's base: an image key from any of your assets (as get_asset " +
|
|
557
|
+
"lists it), or its cdn.voidless.dev URL. Empty = the asset's current base image."
|
|
430
558
|
);
|
|
431
559
|
const frameId = z
|
|
432
560
|
.number()
|
|
@@ -628,18 +756,37 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
628
756
|
"get_asset",
|
|
629
757
|
"Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
|
|
630
758
|
"names, facings and image keys. Frame ids are what frameIndex/items/frameId take elsewhere; image keys " +
|
|
631
|
-
"are what referenceFile and end_frame take.
|
|
632
|
-
|
|
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
|
+
},
|
|
633
769
|
readTool("Get asset"),
|
|
634
|
-
tool(async ({ id, preview }) => {
|
|
770
|
+
tool(async ({ id, frameId, preview }) => {
|
|
635
771
|
const a = await apiGet(`/assets/${id}`);
|
|
636
772
|
const meta = a.meta_data || {};
|
|
637
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
|
+
}
|
|
638
784
|
const lines = [
|
|
639
785
|
`Asset ${a.id} · ${a.type}${meta.image_type ? "/" + meta.image_type : ""} · ${APP_URL}/asset/${a.id}`,
|
|
640
786
|
a.prompt ? `Prompt: ${a.prompt}` : null,
|
|
641
787
|
`Tags: ${a.tags || "(none)"}`,
|
|
642
788
|
meta.size ? `Size: ${meta.size}px · ${meta.dimension || ""} · style ${meta.style || "-"} · facing ${meta.facing || "-"}` : null,
|
|
789
|
+
worldText(meta, frames),
|
|
643
790
|
a.file ? `Base image: ${a.file}` : null,
|
|
644
791
|
meta.rigged_file ? `Rigged: yes (${(meta.animations || []).length} animations)` : null,
|
|
645
792
|
Array.isArray(meta.video_extensions) && meta.video_extensions.length
|
|
@@ -668,11 +815,13 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
668
815
|
const data = await apiGet("/generate/multi-animation/presets");
|
|
669
816
|
const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
|
|
670
817
|
const perClip = Number(data.pricePerClip) > 0 ? Number(data.pricePerClip) : 15;
|
|
818
|
+
const perHdClip = Number(data.pricePerHdClip) > 0 ? Number(data.pricePerHdClip) : 25;
|
|
671
819
|
return {
|
|
672
820
|
content: [
|
|
673
821
|
{
|
|
674
822
|
type: "text",
|
|
675
|
-
text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip
|
|
823
|
+
text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip, ${perHdClip} for a 1024 HD image ` +
|
|
824
|
+
"(drawn at 1080P); mirrored left-side clips are free. " +
|
|
676
825
|
`Up to ${data.maxImages} images per run.`,
|
|
677
826
|
},
|
|
678
827
|
],
|
|
@@ -751,17 +900,29 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
751
900
|
server.tool(
|
|
752
901
|
"generate_image",
|
|
753
902
|
"Generate a game sprite/asset image. Infer every field from the request: art STYLE, colour PALETTE, " +
|
|
754
|
-
"asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType.
|
|
755
|
-
"(flux2-klein, 1 credit
|
|
756
|
-
"seedream-5-flash 10, gpt-image-2.5
|
|
757
|
-
"2 and uses flux2-klein).
|
|
758
|
-
"
|
|
903
|
+
"asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Leave model out unless the user names one: " +
|
|
904
|
+
"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, " +
|
|
905
|
+
"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 " +
|
|
906
|
+
"2 and uses flux2-klein). ASSET SHEETS: type 'artwork' + model 'gpt-image-2.6' + size 2048 (20 credits) or '4k' " +
|
|
907
|
+
"(30 credits) draws one big picture holding a whole matching set (e.g. 'nine fantasy RPG items in a grid, clear space " +
|
|
908
|
+
"between them, plain light background') at full resolution; cut it into one asset per item with extract_parts " +
|
|
909
|
+
"save_as 'assets' (each cut-out a 1024 sprite). Call it once per request and " +
|
|
910
|
+
"show the result; ask the user before generating again (each call spends credits). Every image is drawn in " +
|
|
911
|
+
"exactly one of Voidless's fixed style presets, and the result says which one was used and why.",
|
|
759
912
|
{
|
|
760
|
-
prompt: z
|
|
913
|
+
prompt: z
|
|
914
|
+
.string()
|
|
915
|
+
.max(1000)
|
|
916
|
+
.describe(
|
|
917
|
+
"The subject (max 300 chars; an asset sheet, type 'artwork' at size 2048 or '4k', up to 1000 so each item " +
|
|
918
|
+
"and its view can be named). Prefer the style/palette fields; if style is empty, a style the prompt names " +
|
|
919
|
+
"(e.g. 'watercolour frost mage', 'a cel-shaded golem') picks that preset and those words are taken out of the " +
|
|
920
|
+
"prompt, so the preset's own style words are the only ones the model sees."
|
|
921
|
+
),
|
|
761
922
|
model: z
|
|
762
|
-
.enum(["flux2-klein", "gpt-image-2.5", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
|
|
763
|
-
.
|
|
764
|
-
.describe("
|
|
923
|
+
.enum(["flux2-klein", "gpt-image-2.5", "gpt-image-2.6", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
|
|
924
|
+
.optional()
|
|
925
|
+
.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."),
|
|
765
926
|
type: z
|
|
766
927
|
.enum(["object", "character", "character portrait", "artwork", "tileset", "ui"])
|
|
767
928
|
.describe(
|
|
@@ -770,22 +931,35 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
770
931
|
"tilesetKind; below 512 it is drawn at 512 and pixelated); 'ui' = interface pieces (see uiMode)."
|
|
771
932
|
),
|
|
772
933
|
dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
|
|
773
|
-
size:
|
|
774
|
-
.union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)])
|
|
934
|
+
size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024, 2048, "4k")
|
|
775
935
|
.default(512)
|
|
776
|
-
.describe(
|
|
777
|
-
|
|
936
|
+
.describe(
|
|
937
|
+
"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 " +
|
|
938
|
+
"ASSET-SHEET sizes, only for type 'artwork' with model 'gpt-image-2.6' in a smooth (not pixel) style: 2048 = " +
|
|
939
|
+
"2048x2048 (aspect '16:9': 2560x1440), '4k' = 2880x2880 (aspect '16:9': 3840x2160), kept at that size. For a " +
|
|
940
|
+
"set of matching assets to cut apart with extract_parts save_as 'assets'."
|
|
941
|
+
),
|
|
778
942
|
style: z
|
|
779
943
|
.string()
|
|
780
944
|
.default("")
|
|
781
945
|
.describe(
|
|
782
946
|
"Style preset: pixel-art, cartoon, cel-shading, flat, hand-drawn, paper-cutout, comic-book, anime, watercolor, " +
|
|
783
|
-
"low-poly, 3d-render, claymation, photorealistic, voxel.
|
|
947
|
+
"low-poly, 3d-render, claymation, photorealistic, voxel. Always ends up as one of these: another value is " +
|
|
948
|
+
"mapped to the nearest preset ('watercolour', '8-bit', 'realistic'), or the default if none fits. Empty = the " +
|
|
949
|
+
"preset the prompt names, at 512/1024 for non-pixel styles (at 32-384 the result is pixel art); otherwise " +
|
|
950
|
+
"pixel art below 512, cartoon at 512/1024."
|
|
784
951
|
),
|
|
785
952
|
palette: z
|
|
786
|
-
.enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia"])
|
|
953
|
+
.enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia", "nes", "pico8", "c64", "cga", "endesga32", "sunset", "ice", "dungeon", "forest", "desert", "candy", "cyberpunk"])
|
|
787
954
|
.default("none")
|
|
788
|
-
.describe(
|
|
955
|
+
.describe(
|
|
956
|
+
"Colour-scheme preset, independent of style. 'none' = the palette the prompt names (e.g. 'in a neon palette', " +
|
|
957
|
+
"'greyscale'), else the style's own colours. Pixel art (32-384 px, or a pixel style) is also held to the " +
|
|
958
|
+
"preset's colours: gameboy (the 4 DMG greens), cga (4), pico8 (16 fixed), c64 (16 fixed), nes (16 from the " +
|
|
959
|
+
"NES set), endesga32 (32 fixed), black-and-white (5 greys), sepia (8); retro, earthy, sunset, ice, dungeon, " +
|
|
960
|
+
"forest, desert, candy and cyberpunk set the mood and cap the colours (12-16). Edits, rotations and " +
|
|
961
|
+
"animations of the asset keep its palette. There is no colour-count setting: Voidless picks it by size."
|
|
962
|
+
),
|
|
789
963
|
facing: z.enum(["forward", "right"]).default("forward").describe("Characters: front view or side profile. Side profiles rotate best."),
|
|
790
964
|
pose: z
|
|
791
965
|
.enum(["idle", "tpose"])
|
|
@@ -803,29 +977,59 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
803
977
|
"type 'tileset' only: 'platformer' = side-view 3x3 ground sheet (default for 2d); 'wang' = top-down two-terrain " +
|
|
804
978
|
"4x5 auto-tile sheet in the free Tileset Generator's layout (default for top down; name both terrains, e.g. " +
|
|
805
979
|
"'a cobblestone path in grass'), every edge matched so any arrangement tiles seamlessly; 'blob17' = top-down " +
|
|
806
|
-
"17-piece sheet of one terrain on transparent. Top-down sheets have 256 px tiles; size
|
|
980
|
+
"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)"
|
|
807
981
|
),
|
|
808
982
|
aspect: z
|
|
809
983
|
.enum(["1:1", "16:9"])
|
|
810
984
|
.default("1:1")
|
|
811
|
-
.describe("type 'artwork' at size 512 or
|
|
985
|
+
.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"),
|
|
812
986
|
uiMode: z
|
|
813
987
|
.enum(["graphic", "title", "text"])
|
|
814
988
|
.default("graphic")
|
|
815
989
|
.describe("type 'ui' only: 'graphic' = button/panel/HUD piece, 'title' = game-title wordmark spelling the prompt, 'text' = plain label spelling the prompt"),
|
|
816
990
|
seed: z.number().int().min(0).default(0).describe("RNG seed (0 = random)"),
|
|
817
991
|
referenceImage: z.string().default("").describe("Optional raw base64 image (no data: prefix) to base the result on"),
|
|
818
|
-
styleImage: z
|
|
992
|
+
styleImage: z
|
|
993
|
+
.string()
|
|
994
|
+
.default("")
|
|
995
|
+
.describe(
|
|
996
|
+
"Optional image whose art style to copy: an image key from one of your assets (get_asset lists them, e.g. " +
|
|
997
|
+
"assets/64/images/<id>.png) or its cdn.voidless.dev URL. The server loads it at full resolution. Prefer " +
|
|
998
|
+
"this (or styleAssetId) over base64: get_image_data only returns a small thumbnail. With gpt-image-2.6 " +
|
|
999
|
+
"the picture itself is shown to the model; other models get a description of its style"
|
|
1000
|
+
),
|
|
1001
|
+
styleAssetId: z
|
|
1002
|
+
.number()
|
|
1003
|
+
.int()
|
|
1004
|
+
.optional()
|
|
1005
|
+
.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"),
|
|
819
1006
|
},
|
|
820
1007
|
writeTool("Generate image"),
|
|
821
|
-
tool(async ({ facing, pose, bodyType, palette, uiMode, tilesetKind, aspect, ...rest }) => {
|
|
1008
|
+
tool(async ({ facing, pose, bodyType, palette, uiMode, tilesetKind, aspect, styleAssetId, ...rest }) => {
|
|
1009
|
+
const sheetSized = rest.size === 2048 || rest.size === "4k";
|
|
1010
|
+
if (String(rest.prompt || "").length > promptLimit(rest.type, rest.size)) {
|
|
1011
|
+
throw new Error(
|
|
1012
|
+
`prompt is ${String(rest.prompt).length} characters; the limit is 300 (1000 for an asset sheet: type 'artwork' ` +
|
|
1013
|
+
"at size 2048 or '4k'). For a whole set drawn in the game's camera view, use generate_asset_set."
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
if (sheetSized) {
|
|
1017
|
+
if (rest.type !== "artwork" || rest.model !== "gpt-image-2.6") {
|
|
1018
|
+
throw new Error(
|
|
1019
|
+
`size ${rest.size} is an asset-sheet size: only for type 'artwork' with model 'gpt-image-2.6'. ` +
|
|
1020
|
+
"Use 512, 768 or 1024 otherwise."
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
if (rest.size === "4k") rest.size = 3840;
|
|
1024
|
+
}
|
|
822
1025
|
const wide = rest.type === "artwork" && aspect === "16:9" ? "16:9" : "1:1";
|
|
823
1026
|
const tileset = rest.type === "tileset";
|
|
824
1027
|
// Tilesets are GPT-Image only (the server enforces it too); the kind rides in options.mode.
|
|
825
1028
|
if (tileset) rest.model = "gpt-image-2.5";
|
|
826
1029
|
const mode = tileset ? tilesetKind || "" : uiMode;
|
|
827
|
-
const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode, aspect: wide } };
|
|
828
|
-
|
|
1030
|
+
const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode, aspect: wide }, ...styleRef(rest.styleImage, styleAssetId) };
|
|
1031
|
+
const data = await apiPost("/generate/image", body);
|
|
1032
|
+
return assetResult(data, "Image", [styleLine(data)]);
|
|
829
1033
|
})
|
|
830
1034
|
);
|
|
831
1035
|
|
|
@@ -838,7 +1042,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
838
1042
|
prompt: z.string().describe("What to change"),
|
|
839
1043
|
referenceFile,
|
|
840
1044
|
frameId,
|
|
841
|
-
images: z.array(z.string()).max(4).default([]).describe("Optional extra reference images: image keys from get_asset or
|
|
1045
|
+
images: z.array(z.string()).max(4).default([]).describe("Optional extra reference images: image keys from any of your assets (get_asset lists them), or their cdn.voidless.dev URLs. Other links are refused: upload the picture first"),
|
|
842
1046
|
remove_bg: z.boolean().default(true).describe("Remove the background from the result, as the app's editor does. Set false for artwork or a scene that should keep its background"),
|
|
843
1047
|
},
|
|
844
1048
|
writeTool("Edit image"),
|
|
@@ -909,7 +1113,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
909
1113
|
"rotate_sprite",
|
|
910
1114
|
"Turn a sprite into an 8-way set (front, back, sides and diagonals) in one pass. The result is a " +
|
|
911
1115
|
"'rotations - 8-way' frame whose facings line up with its images; pass those to generate_animation_set " +
|
|
912
|
-
"to animate each direction. Works best from a side-profile sprite.
|
|
1116
|
+
"to animate each direction. Works best from a side-profile sprite. 20 credits.",
|
|
913
1117
|
{ id: assetId, referenceFile, frameId },
|
|
914
1118
|
writeTool("Rotate sprite (8-way)"),
|
|
915
1119
|
tool(async (args) => framesResult(await apiPost("/generate/image-rotations", args), args.id, "8-way rotation"))
|
|
@@ -917,17 +1121,44 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
917
1121
|
|
|
918
1122
|
server.tool(
|
|
919
1123
|
"extract_parts",
|
|
920
|
-
"
|
|
921
|
-
"
|
|
1124
|
+
"Cut an image into its separate pieces. Two uses: (1) a sheet of game items (characters, props, trees...) " +
|
|
1125
|
+
"into one NEW asset per item (the default, save_as 'assets'): each item at its own proportions, framed on " +
|
|
1126
|
+
"a square at the sheet's size (1024 for a 2048/4k asset sheet from generate_image, cut from the " +
|
|
1127
|
+
"full-resolution sheet) " +
|
|
1128
|
+
"like a generated sprite (filling the frame), named after the item, a character or an object, in the " +
|
|
1129
|
+
"sheet's projects and style. " +
|
|
1130
|
+
"Those are generated images like any other: rotate_sprite, animate_sprite, generate_animation_set and " +
|
|
1131
|
+
"resize_sprite all take them; (2) a character sprite into body parts (head, torso, arms, legs...) as " +
|
|
1132
|
+
"'layer' frames on that asset, for rigging or cut-out animation: pass save_as 'frames'. 10 credits either " +
|
|
1133
|
+
"way. Frames: call get_asset afterwards for their ids.",
|
|
922
1134
|
{
|
|
923
1135
|
id: assetId,
|
|
924
|
-
prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = the standard rig parts"),
|
|
1136
|
+
prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = every separate item (assets) or the standard rig parts (frames)"),
|
|
1137
|
+
save_as: z.enum(["assets", "frames"]).default("assets")
|
|
1138
|
+
.describe("assets = each piece saved as its own new asset (default); frames = body-part layer frames on this asset, for rigging"),
|
|
1139
|
+
asset_type: z.enum(["auto", "character", "object"]).default("auto")
|
|
1140
|
+
.describe("save_as 'assets' only: the new assets' type. auto = character when the item's name is one (hero, golem, slime, wolf...), else object"),
|
|
925
1141
|
referenceFile,
|
|
926
1142
|
frameId,
|
|
927
1143
|
},
|
|
928
1144
|
writeTool("Extract sprite parts"),
|
|
929
1145
|
tool(async (args) => {
|
|
930
1146
|
const data = await apiPost("/generate/image-parts", args);
|
|
1147
|
+
if (args.save_as === "assets") {
|
|
1148
|
+
const assets = Array.isArray(data.assets) ? data.assets : [];
|
|
1149
|
+
const lines = [
|
|
1150
|
+
`Saved ${assets.length} item${assets.length === 1 ? "" : "s"} from asset ${args.id} as new assets:`,
|
|
1151
|
+
...assets.map((a) => `#${a.id} ${a.prompt} (${a.image_type}) · ${APP_URL}/asset/${a.id}`),
|
|
1152
|
+
money(data),
|
|
1153
|
+
].filter(Boolean);
|
|
1154
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
1155
|
+
for (const a of assets.slice(0, MAX_INLINE_IMAGES)) {
|
|
1156
|
+
const image = await inlineImage(a.file);
|
|
1157
|
+
if (image) content.push(image);
|
|
1158
|
+
}
|
|
1159
|
+
const items = assets.map((a) => mediaItem(a.file, `#${a.id} ${a.prompt || ""}`.trim(), { link: `${APP_URL}/asset/${a.id}` }));
|
|
1160
|
+
return withGallery({ content }, items, "Items");
|
|
1161
|
+
}
|
|
931
1162
|
// The ids returned here are off by (parts - 1): the server numbers from the last appended
|
|
932
1163
|
// frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
|
|
933
1164
|
const names = (data.frames || []).map((f) => f.name).filter(Boolean);
|
|
@@ -942,31 +1173,104 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
942
1173
|
})
|
|
943
1174
|
);
|
|
944
1175
|
|
|
1176
|
+
// Asset sets (docs/features/asset-sets.md): sheets drawn in the game's camera view, cut, tagged and checked.
|
|
1177
|
+
const assetSetItem = z.object({
|
|
1178
|
+
name: z.string().min(1).max(80).describe("What it is, e.g. 'knight', 'ice golem', 'treasure chest'"),
|
|
1179
|
+
kind: z.enum(["character", "object", "tile"]).optional()
|
|
1180
|
+
.describe("character (rotated and animated), object (a prop) or tile. Omitted: guessed from the name"),
|
|
1181
|
+
body: z.enum(["humanoid", "quadruped", "blob", "flying"]).optional()
|
|
1182
|
+
.describe("Characters: body plan (a wolf is quadruped, a slime blob, a bat flying). Omitted: guessed"),
|
|
1183
|
+
notes: z.string().max(240).optional().describe("Optional details: colours, outfit, what it holds"),
|
|
1184
|
+
shape: z.enum(["tall", "wide", "square"]).optional().describe("Optional: how it fits a cell (a tree is tall, a cart wide). Omitted: guessed"),
|
|
1185
|
+
size: z.number().min(0.05).max(50).optional()
|
|
1186
|
+
.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"),
|
|
1187
|
+
reference: z.boolean().optional().describe("This item is the set's unit of height (default: the first humanoid character)"),
|
|
1188
|
+
});
|
|
1189
|
+
async function assetSetResult(data, label) {
|
|
1190
|
+
const content = [{ type: "text", text: assetSetText(data, APP_URL) }];
|
|
1191
|
+
for (const p of (data.pieces || []).slice(0, MAX_INLINE_IMAGES)) {
|
|
1192
|
+
const image = await inlineImage(p.file);
|
|
1193
|
+
if (image) content.push(image);
|
|
1194
|
+
}
|
|
1195
|
+
const items = [
|
|
1196
|
+
...(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}` })),
|
|
1197
|
+
...(data.sheets || []).map((s) => mediaItem(s.file, `Sheet #${s.asset_id}`, { link: `${APP_URL}/asset/${s.asset_id}` })),
|
|
1198
|
+
];
|
|
1199
|
+
return withGallery({ content }, items, label);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
server.tool(
|
|
1203
|
+
"generate_asset_set",
|
|
1204
|
+
"Make a whole matching game art set in the game's CAMERA VIEW: characters, props and tiles drawn on 2048 " +
|
|
1205
|
+
"GPT-Image 2.6 sheets, cut into one asset per item, each tagged with the view's dimension and facing so " +
|
|
1206
|
+
"rotate_sprite and the animation tools use the right angle. view: 'platformer' (side profile facing right, eye " +
|
|
1207
|
+
"level), 'topdown' (classic RPG, ~45° camera, characters head-on and symmetrical), 'topdown-overhead' (straight " +
|
|
1208
|
+
"down), 'isometric'. Items are planned onto sheets (about 3-4 characters, 4-6 objects or 3 tall props a sheet; " +
|
|
1209
|
+
"the first sheet, or style_asset_id, is the style every later sheet copies at full resolution). Cost: 20 a " +
|
|
1210
|
+
"sheet + 10 a cut; pass estimate_only to see the plan and price first. Every piece gets a free view check " +
|
|
1211
|
+
"(head-on for top-down, a profile for platformer) and failures are reported with a redraw offer; nothing else " +
|
|
1212
|
+
"is spent (no rotations or animations). Voidless+ only. Returns set_id: add_to_asset_set adds to it later. " +
|
|
1213
|
+
"Every piece gets a world_height in heights of the set's reference character (the first humanoid, or the item " +
|
|
1214
|
+
"marked reference), measured on the sheet before the cut-outs are reframed, or the item's own `size` when given. " +
|
|
1215
|
+
"In a game, size every piece from world_height and the export's scale data (export_godot_spriteframes with " +
|
|
1216
|
+
"asset_id), never by guessing from the image size: all cut-outs are framed to the same pixel height.",
|
|
1217
|
+
{
|
|
1218
|
+
view: z.enum(["platformer", "topdown", "topdown-overhead", "isometric"]).describe("The game's camera view"),
|
|
1219
|
+
items: z.array(assetSetItem).min(1).max(24).describe("Everything the set needs, e.g. [{name:'knight'}, {name:'frost wolf', body:'quadruped'}]"),
|
|
1220
|
+
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"),
|
|
1221
|
+
style_words: z.string().max(300).optional().describe("Optional extra style words for every sheet, e.g. 'muted colours, matte shading, medium outlines'"),
|
|
1222
|
+
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"),
|
|
1223
|
+
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"),
|
|
1224
|
+
estimate_only: z.boolean().default(false).describe("true = only the sheet plan and the price; nothing is drawn or charged"),
|
|
1225
|
+
},
|
|
1226
|
+
writeTool("Generate asset set"),
|
|
1227
|
+
tool(async (args) => assetSetResult(await apiPost("/generate/asset-set", args), "Asset set"))
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
server.tool(
|
|
1231
|
+
"add_to_asset_set",
|
|
1232
|
+
"Add items to an existing asset set (from generate_asset_set), reusing everything it was made with: its view, " +
|
|
1233
|
+
"style preset, style words, palette and style sheet. Or redraw_failed: true draws again, on a small sheet in " +
|
|
1234
|
+
"the same style, just the pieces that failed their view check or were not cut out. Same price: 20 a sheet + 10 " +
|
|
1235
|
+
"a cut; estimate_only shows it first. New pieces get world_height in the same reference character's heights " +
|
|
1236
|
+
"(give `size` for each new item whose size matters: a new sheet has no reference character on it to measure against).",
|
|
1237
|
+
{
|
|
1238
|
+
set_id: z.number().int().describe("The set's id (generate_asset_set's set_id: its first sheet's asset id)"),
|
|
1239
|
+
items: z.array(assetSetItem).max(24).default([]).describe("New items for the set"),
|
|
1240
|
+
redraw_failed: z.boolean().default(false).describe("Also redraw the set's pieces that failed the view check or went missing"),
|
|
1241
|
+
estimate_only: z.boolean().default(false).describe("true = only the plan and the price; nothing is drawn or charged"),
|
|
1242
|
+
},
|
|
1243
|
+
writeTool("Add to asset set"),
|
|
1244
|
+
tool(async (args) => assetSetResult(await apiPost("/generate/asset-set", args), "Asset set"))
|
|
1245
|
+
);
|
|
1246
|
+
|
|
945
1247
|
server.tool(
|
|
946
1248
|
"upload_image",
|
|
947
1249
|
"Add an image the user already has to their library, so it can be animated, rotated or edited. It must " +
|
|
948
|
-
|
|
1250
|
+
`be square at ${PIXEL_SIZE_TEXT}, 512, 768 or 1024 px. Give it as a public URL (best: a long base64 string is ` +
|
|
1251
|
+
"easy to garble) or as base64. Free.",
|
|
949
1252
|
{
|
|
950
|
-
|
|
1253
|
+
url: z.string().optional().describe("Public http(s) link to the image. Use this when the image is online"),
|
|
1254
|
+
image: z.string().optional().describe("Base64 image instead of url (a data: prefix is stripped automatically)"),
|
|
951
1255
|
type: z.enum(["object", "character", "character portrait", "artwork"]).default("character"),
|
|
952
1256
|
remove_bg: z.boolean().default(false).describe("Remove the background on upload"),
|
|
953
1257
|
},
|
|
954
1258
|
writeTool("Upload image"),
|
|
955
|
-
tool(async ({ image, ...rest }) => {
|
|
956
|
-
|
|
957
|
-
|
|
1259
|
+
tool(async ({ image, url, ...rest }) => {
|
|
1260
|
+
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.");
|
|
1261
|
+
const body = url ? { url, ...rest } : { image: String(image).replace(/^data:[^,]*,/, ""), ...rest };
|
|
1262
|
+
return assetResult(await apiPost("/upload/image", body), "Upload");
|
|
958
1263
|
})
|
|
959
1264
|
);
|
|
960
1265
|
|
|
961
1266
|
server.tool(
|
|
962
1267
|
"pixelate_image",
|
|
963
|
-
"Convert any image to pixel art at a target size
|
|
1268
|
+
"Convert any image to pixel art at a target size (the colour count follows the size). Returns the image; nothing is " +
|
|
964
1269
|
"saved to the library. 1 credit.",
|
|
965
1270
|
{
|
|
966
1271
|
image: z.string().describe("Base64 source image (a data: prefix is stripped automatically)"),
|
|
967
|
-
size:
|
|
968
|
-
.describe(
|
|
969
|
-
colours: z.number().int().min(4).max(12).default(8),
|
|
1272
|
+
size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024).default(64)
|
|
1273
|
+
.describe(`Pixel size: ${PIXEL_SIZE_TEXT}, or 512 and 1024, drawn on a real 4x grid (128 / 256 art pixels)`),
|
|
970
1274
|
},
|
|
971
1275
|
writeTool("Pixelate image"),
|
|
972
1276
|
tool(async ({ image, ...rest }) => {
|
|
@@ -993,9 +1297,9 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
993
1297
|
url: z.string().optional().describe("Public http(s) link to the image or video. Use this for videos"),
|
|
994
1298
|
file: z.string().optional().describe("Base64 file instead of url (a data: prefix is stripped automatically)"),
|
|
995
1299
|
kind: z.enum(["image", "video"]).optional().describe("What the file is. Inferred from the url's extension when omitted"),
|
|
996
|
-
style: z.enum(["pixel", "smooth"]).default("pixel").describe("pixel = pixel art at 32-1024; smooth = clean cut-out at 512 or 1024"),
|
|
997
|
-
size:
|
|
998
|
-
.describe(
|
|
1300
|
+
style: z.enum(["pixel", "smooth"]).default("pixel").describe("pixel = pixel art at 32-1024; smooth = clean cut-out at 512, 768 or 1024"),
|
|
1301
|
+
size: sizeSchema(...PIXEL_SIZES, 512, 768, 1024).optional()
|
|
1302
|
+
.describe(`Output size. Pixel: ${PIXEL_SIZE_TEXT}, 512 or 1024 (default 64); smooth: 512 (default), 768 or 1024`),
|
|
999
1303
|
motion: z.enum(["loop", "once"]).default("loop").describe("Video only: loop = find the cleanest loop; once = keep the whole clip"),
|
|
1000
1304
|
},
|
|
1001
1305
|
writeTool("Clean up sprite"),
|
|
@@ -1003,7 +1307,7 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
1003
1307
|
if (!url && !file) throw new Error("Give the file as a url or as base64 in file.");
|
|
1004
1308
|
const pixel = style !== "smooth";
|
|
1005
1309
|
const out = size || (pixel ? 64 : 512);
|
|
1006
|
-
if (!pixel &&
|
|
1310
|
+
if (!pixel && ![512, 768, 1024].includes(out)) throw new Error("Smooth art comes back at 512, 768 or 1024.");
|
|
1007
1311
|
const guessed = /\.(mp4|webm|mov|m4v)(\?|$)/i.test(String(url || "")) ? "video"
|
|
1008
1312
|
: /^data:video\//i.test(String(file || "")) ? "video" : "image";
|
|
1009
1313
|
const body = { kind: kind || guessed, size: out, pixel, loop: motion };
|
|
@@ -1034,17 +1338,31 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
1034
1338
|
// Animation
|
|
1035
1339
|
// ---------------------------------------------------------------------------
|
|
1036
1340
|
|
|
1341
|
+
// Room for big moves (image-api fit to room): a character that fills its frame is shrunk a little,
|
|
1342
|
+
// inside the same frame, so kicks and falls don't run off the edge.
|
|
1343
|
+
const room = z.enum(["auto", "on", "off"]).default("auto").describe(
|
|
1344
|
+
"Room for big moves. auto: when the motion is big (a kick, a special, a fall, a jump) and the character " +
|
|
1345
|
+
"fills its frame, it is drawn a little smaller in the same frame so nothing is cut off. on: always do " +
|
|
1346
|
+
"that. off: never. Making one character's moves over several calls, use on for every call so the " +
|
|
1347
|
+
"character stays one size"
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1037
1350
|
server.tool(
|
|
1038
1351
|
"animate_sprite",
|
|
1039
1352
|
"Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
|
|
1040
1353
|
"(walk + run + attack, or every direction) use generate_animation_set instead: one job, " +
|
|
1041
|
-
"15 credits per clip. 15 credits.",
|
|
1354
|
+
"15 credits per clip. 15 credits; 25 for a 1024 HD image (drawn at 1080P).",
|
|
1042
1355
|
{
|
|
1043
1356
|
id: assetId,
|
|
1044
1357
|
prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
|
|
1045
1358
|
referenceFile,
|
|
1046
|
-
end_frame: z.string().default("").describe(
|
|
1359
|
+
end_frame: z.string().default("").describe(
|
|
1360
|
+
"Optional image for the clip to end on: an image key from any of your assets (get_asset lists them), or its " +
|
|
1361
|
+
"cdn.voidless.dev URL, e.g. the start image to loop, or another sprite's pose. Empty = free ending. An end " +
|
|
1362
|
+
"image of another size is scaled to the start image's; one of a very different shape is refused (nothing charged)"
|
|
1363
|
+
),
|
|
1047
1364
|
frameId,
|
|
1365
|
+
room,
|
|
1048
1366
|
},
|
|
1049
1367
|
writeTool("Animate sprite"),
|
|
1050
1368
|
tool(async (args) => framesResult(await apiPost("/generate/image-animation", { ...args, mode: "f" }), args.id, "Animation"))
|
|
@@ -1074,7 +1392,8 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
1074
1392
|
"'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: " +
|
|
1075
1393
|
"raise the sword to guard'); for one image a plain 'idle, run, jump' also works. Several actions on several " +
|
|
1076
1394
|
"images: every image gets every action.\n" +
|
|
1077
|
-
"Cost: 15 credits per clip
|
|
1395
|
+
"Cost: 15 credits per clip, 25 per clip of a 1024 HD image (drawn at 1080P); mirrored clips are free and " +
|
|
1396
|
+
"clips that fail are refunded. Clips that come back to " +
|
|
1078
1397
|
"their start (idle, walk, run, attack, jump, hurt, dash) end on their own first frame, so they loop; one-off " +
|
|
1079
1398
|
"clips such as a death do not. Clips are added to their source asset as frames named after the action.",
|
|
1080
1399
|
{
|
|
@@ -1087,6 +1406,9 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
1087
1406
|
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)"),
|
|
1088
1407
|
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"),
|
|
1089
1408
|
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"),
|
|
1409
|
+
room: room.describe("Room for big moves, decided once per sprite for the whole set so it is one size in every clip. " +
|
|
1410
|
+
"auto: if any clip is a big move (kick, special, fall, jump) and the character fills its frame, every clip of " +
|
|
1411
|
+
"that sprite draws it a little smaller. on: always. off: never"),
|
|
1090
1412
|
},
|
|
1091
1413
|
writeTool("Generate animation set"),
|
|
1092
1414
|
tool(async (args) => resultsContent(await apiPost("/generate/multi-animation", args), "Animation set generated."))
|
|
@@ -1102,6 +1424,80 @@ export function createVoidlessServer(API_KEY, transport = "stdio") {
|
|
|
1102
1424
|
tool(async ({ id }) => framesResult(await apiPost("/generate/flip", { id }), id, "Flip"))
|
|
1103
1425
|
);
|
|
1104
1426
|
|
|
1427
|
+
server.tool(
|
|
1428
|
+
"resize_sprite",
|
|
1429
|
+
`Free. Only for images generated in Voidless (not uploads): pixel sprites of size ${PIXEL_SIZE_TEXT}, or HD ` +
|
|
1430
|
+
"(non-pixel, square 512/1024) images. Re-runs Voidless's pixel pass at a new size from the " +
|
|
1431
|
+
"art it stored, so the base image, its rotations, edits and every animation come out at the new size on one " +
|
|
1432
|
+
"palette and the character keeps its design (unlike generating it again smaller). On an HD image it makes a " +
|
|
1433
|
+
"pixel version at `size`: generate HD, then resize to 64 and to 32 for pixel versions. Makes a NEW asset; the " +
|
|
1434
|
+
"original is untouched.",
|
|
1435
|
+
{
|
|
1436
|
+
id: assetId,
|
|
1437
|
+
size: sizeSchema(...PIXEL_SIZES).optional()
|
|
1438
|
+
.describe(`New size in px: ${PIXEL_SIZE_TEXT}. Omit to keep the size (required for an HD image)`),
|
|
1439
|
+
},
|
|
1440
|
+
writeTool("Resize sprite"),
|
|
1441
|
+
tool(async ({ id, size }) => {
|
|
1442
|
+
const data = await apiPost("/generate/resize", { assetId: id, size });
|
|
1443
|
+
const asset = data.asset || {};
|
|
1444
|
+
const md = asset.meta_data || {};
|
|
1445
|
+
const link = `${APP_URL}/asset/${asset.id}`;
|
|
1446
|
+
const skipped = Array.isArray(data.skipped) ? data.skipped : [];
|
|
1447
|
+
const lines = [
|
|
1448
|
+
`Resized asset ${id} into a new asset: ${md.size} px, ${md.colours} colours. The original is unchanged. Free.`,
|
|
1449
|
+
`Asset ID: ${asset.id} · ${link}`,
|
|
1450
|
+
Array.isArray(data.palette) && data.palette.length ? `Palette: ${data.palette.join(" ")}` : null,
|
|
1451
|
+
skipped.length ? `${skipped.length} frame set(s) were left out: ` +
|
|
1452
|
+
skipped.map((x) => (x && typeof x === "object" ? `${x.name || `frame ${x.id}`} (${x.reason || "no stored renders"})` : `frame ${x}`)).join("; ") +
|
|
1453
|
+
"." : null,
|
|
1454
|
+
].filter(Boolean);
|
|
1455
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
1456
|
+
const frames = (md.frames || []).filter((f) => !f.archived);
|
|
1457
|
+
if (frames.length) content.push({ type: "text", text: "Frames:\n" + frames.map(frameLine).join("\n") });
|
|
1458
|
+
const image = await inlineImage(asset.file);
|
|
1459
|
+
if (image) content.push(image);
|
|
1460
|
+
const items = [mediaItem(asset.file, "Resized", { link }), ...frames.flatMap((f) => frameItems(f, asset.id))];
|
|
1461
|
+
return withGallery({ content }, items, "Resized");
|
|
1462
|
+
})
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
server.tool(
|
|
1466
|
+
"match_palette",
|
|
1467
|
+
`Free. Only for images generated in Voidless (not uploads): pixel sprites of size ${PIXEL_SIZE_TEXT} (an HD ` +
|
|
1468
|
+
"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 " +
|
|
1469
|
+
"built from all of them as colour ramps, or given as hex colours, or led by one sprite's own colours; every " +
|
|
1470
|
+
"frame of every sprite is remapped along the ramps so shading stays smooth. Each sprite gets a NEW asset " +
|
|
1471
|
+
"(originals untouched).",
|
|
1472
|
+
{
|
|
1473
|
+
ids: z.array(z.number().int()).min(1).max(12).describe("The image assets to put on one palette (1-12)"),
|
|
1474
|
+
colours: z.number().int().min(8).max(64).optional()
|
|
1475
|
+
.describe("Size of the SHARED palette all of them draw from (8-64, default 24). Each sprite keeps roughly its own colour count"),
|
|
1476
|
+
palette: z.array(z.string().regex(/^#?[0-9a-fA-F]{6}$/)).min(1).max(64).optional()
|
|
1477
|
+
.describe("Use this palette instead, as hex colours like \"#1a2b3c\""),
|
|
1478
|
+
lead: z.number().int().optional().describe("One of ids whose own colours are kept exactly; the others follow it"),
|
|
1479
|
+
},
|
|
1480
|
+
writeTool("Match palette"),
|
|
1481
|
+
tool(async ({ ids, colours, palette, lead }) => {
|
|
1482
|
+
const data = await apiPost("/generate/match-palette", { assetIds: ids, colours, palette, leadAssetId: lead });
|
|
1483
|
+
const assets = Array.isArray(data.assets) ? data.assets : [];
|
|
1484
|
+
const shared = Array.isArray(data.palette) ? data.palette : [];
|
|
1485
|
+
const lines = [
|
|
1486
|
+
`Matched ${assets.length} sprite${assets.length === 1 ? "" : "s"} on one ${shared.length}-colour palette. Originals unchanged. Free.`,
|
|
1487
|
+
...assets.map((a) => `Asset ${a.meta_data?.derived_from?.assetId} -> new asset ${a.id} · ${APP_URL}/asset/${a.id}`),
|
|
1488
|
+
`Palette: ${shared.join(" ")}`,
|
|
1489
|
+
Array.isArray(data.skipped) && data.skipped.length ? `Not matched: ${data.skipped.join(", ")}` : null,
|
|
1490
|
+
].filter(Boolean);
|
|
1491
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
1492
|
+
for (const a of assets.slice(0, MAX_INLINE_IMAGES)) {
|
|
1493
|
+
const image = await inlineImage(a.file);
|
|
1494
|
+
if (image) content.push(image);
|
|
1495
|
+
}
|
|
1496
|
+
const items = assets.map((a) => mediaItem(a.file, `#${a.id}`, { link: `${APP_URL}/asset/${a.id}` }));
|
|
1497
|
+
return withGallery({ content }, items, "Palette matched");
|
|
1498
|
+
})
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1105
1501
|
// ---------------------------------------------------------------------------
|
|
1106
1502
|
// Audio
|
|
1107
1503
|
// ---------------------------------------------------------------------------
|