voidless-mcp 1.6.2 → 1.6.3
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 +1 -0
- package/package.json +2 -1
- package/tools.js +111 -1
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ claude mcp add voidless -e VOIDLESS_API_KEY=void_... -- npx -y voidless-mcp
|
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `animate_sprite` | One 16-frame clip from a description of the motion |
|
|
89
89
|
| `generate_animation_set` | Several clips in one job: a preset (platformer, top-down walk/run/dash/attack/idle) on one sprite, or one directional clip per facing of an 8-way set; the left side is mirrored from the right (`autoFlip` to mirror any right-only clips after the run) |
|
|
90
|
+
| `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 |
|
|
90
91
|
| `flip_left_side` | Mirror the right-side animations into the left side (walk-right -> walk-left), free |
|
|
91
92
|
|
|
92
93
|
**Sound, video and 3D**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voidless-mcp",
|
|
3
3
|
"mcpName": "dev.voidless/voidless",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.3",
|
|
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>",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
38
|
+
"pngjs": "^7.0.0",
|
|
38
39
|
"undici": "^6.29.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/tools.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import pngjs from "pngjs";
|
|
18
|
+
const { PNG } = pngjs;
|
|
17
19
|
import { z } from "zod";
|
|
18
20
|
import { readFileSync } from "node:fs";
|
|
19
21
|
import { Agent, setGlobalDispatcher } from "undici";
|
|
@@ -23,7 +25,7 @@ import { Agent, setGlobalDispatcher } from "undici";
|
|
|
23
25
|
// server does.
|
|
24
26
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
|
|
25
27
|
|
|
26
|
-
export const VERSION = "1.6.
|
|
28
|
+
export const VERSION = "1.6.3";
|
|
27
29
|
const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
|
|
28
30
|
const CDN_URL = "https://cdn.voidless.dev";
|
|
29
31
|
// Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
|
|
@@ -361,6 +363,114 @@ export function createVoidlessServer(API_KEY) {
|
|
|
361
363
|
})
|
|
362
364
|
);
|
|
363
365
|
|
|
366
|
+
// ── image data for HTML ──────────────────────────────────────────────────────────────────
|
|
367
|
+
// Claude's HTML artifacts (and many sandboxes) cannot load images from another site, and an image a
|
|
368
|
+
// tool shows the model cannot be copied out of the conversation into code. So this hands back the
|
|
369
|
+
// picture itself as a small data: URI the client pastes into <img src> or a CSS background. Shrunk to
|
|
370
|
+
// fit a text budget: a 512 PNG as base64 is ~400k characters, far past what a reply can carry.
|
|
371
|
+
const DATA_URI_BUDGET = 60000;
|
|
372
|
+
|
|
373
|
+
function decodePng(buf) {
|
|
374
|
+
const png = PNG.sync.read(buf);
|
|
375
|
+
return { width: png.width, height: png.height, data: png.data };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Box-average downscale (keeps pixel art blocky when the factor is whole, smooth art clean otherwise).
|
|
379
|
+
function shrink(img, w, h) {
|
|
380
|
+
if (img.width === w && img.height === h) return img;
|
|
381
|
+
const out = Buffer.alloc(w * h * 4);
|
|
382
|
+
const sx = img.width / w, sy = img.height / h;
|
|
383
|
+
for (let y = 0; y < h; y++) {
|
|
384
|
+
const y0 = Math.floor(y * sy), y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
|
|
385
|
+
for (let x = 0; x < w; x++) {
|
|
386
|
+
const x0 = Math.floor(x * sx), x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
|
|
387
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
|
388
|
+
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
|
|
389
|
+
const i = (yy * img.width + xx) * 4, al = img.data[i + 3];
|
|
390
|
+
r += img.data[i] * al; g += img.data[i + 1] * al; b += img.data[i + 2] * al; a += al; n++;
|
|
391
|
+
}
|
|
392
|
+
const o = (y * w + x) * 4;
|
|
393
|
+
if (a) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a; }
|
|
394
|
+
out[o + 3] = a / n;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { width: w, height: h, data: out };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function encodeStrip(frames, cell) {
|
|
401
|
+
const png = new PNG({ width: cell * frames.length, height: cell });
|
|
402
|
+
frames.forEach((f, k) => {
|
|
403
|
+
const s = shrink(f, cell, cell);
|
|
404
|
+
for (let y = 0; y < cell; y++) s.data.copy(png.data, (y * png.width + k * cell) * 4, y * cell * 4, (y + 1) * cell * 4);
|
|
405
|
+
});
|
|
406
|
+
return PNG.sync.write(png, { colorType: 6 });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function fetchPng(key) {
|
|
410
|
+
const url = cdnUrl(key);
|
|
411
|
+
if (!url) throw new Error(`No public file for ${key}.`);
|
|
412
|
+
const res = await fetch(url);
|
|
413
|
+
if (!res.ok) throw new Error(`Could not load ${url} (${res.status}).`);
|
|
414
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
415
|
+
if (buf.slice(1, 4).toString() !== "PNG") throw new Error(`${url} is not a PNG; use the URL instead.`);
|
|
416
|
+
return decodePng(buf);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
server.tool(
|
|
420
|
+
"get_image_data",
|
|
421
|
+
"An asset's image as a data: URI to put straight into HTML (<img src=…> or a CSS background), for " +
|
|
422
|
+
"sandboxes that cannot load images from another site, such as Claude's HTML artifacts. For an animation " +
|
|
423
|
+
"or rotation frame it returns ONE horizontal sprite strip (all frames side by side, each size×size) plus a " +
|
|
424
|
+
"CSS snippet that plays it. Shrunk to fit a reply (small sizes are fine for sprites; scale them up in CSS " +
|
|
425
|
+
"with image-rendering: pixelated). Free.",
|
|
426
|
+
{
|
|
427
|
+
id: assetId,
|
|
428
|
+
frameId: z.number().int().optional().describe("Frame id from get_asset (an animation, rotation set or edit). Omit for the base image"),
|
|
429
|
+
imageIndex: z.number().int().optional().describe("Only this image of the frame (e.g. one facing of a rotation set) instead of the strip"),
|
|
430
|
+
size: z.number().int().min(16).max(256).default(128).describe("Pixels per image (square). Lowered automatically if the result would be too long"),
|
|
431
|
+
},
|
|
432
|
+
readTool("Get image data"),
|
|
433
|
+
tool(async ({ id, frameId: fid, imageIndex, size }) => {
|
|
434
|
+
const a = await apiGet(`/assets/${id}`);
|
|
435
|
+
const frames = (a.meta_data && a.meta_data.frames) || [];
|
|
436
|
+
let keys;
|
|
437
|
+
let label = "base image";
|
|
438
|
+
if (fid === undefined || fid === null) {
|
|
439
|
+
keys = [a.file];
|
|
440
|
+
} else {
|
|
441
|
+
const f = frames.find((x) => x && x.id === fid) || frames[fid];
|
|
442
|
+
if (!f) throw new Error(`Asset ${id} has no frame ${fid}. Use get_asset for frame ids.`);
|
|
443
|
+
const images = Array.isArray(f.images) ? f.images : [];
|
|
444
|
+
if (!images.length) throw new Error(`Frame ${fid} has no images.`);
|
|
445
|
+
keys = Number.isInteger(imageIndex) ? [images[imageIndex]].filter(Boolean) : images;
|
|
446
|
+
if (!keys.length) throw new Error(`Frame ${fid} has no image ${imageIndex}.`);
|
|
447
|
+
label = `${f.name || f.type || "frame"}${Number.isInteger(imageIndex) ? ` [${imageIndex}]` : ""}`;
|
|
448
|
+
}
|
|
449
|
+
const decoded = await Promise.all(keys.slice(0, 64).map(fetchPng));
|
|
450
|
+
let cell = Math.min(size, Math.max(...decoded.map((d) => Math.max(d.width, d.height))));
|
|
451
|
+
let png = encodeStrip(decoded, cell);
|
|
452
|
+
while (png.length * 4 / 3 > DATA_URI_BUDGET && cell > 16) {
|
|
453
|
+
cell = Math.max(16, Math.floor(cell * 0.75));
|
|
454
|
+
png = encodeStrip(decoded, cell);
|
|
455
|
+
}
|
|
456
|
+
const uri = `data:image/png;base64,${png.toString("base64")}`;
|
|
457
|
+
const n = decoded.length;
|
|
458
|
+
const lines = [
|
|
459
|
+
n === 1
|
|
460
|
+
? `Asset ${id} ${label}: ${cell}×${cell} PNG as a data URI (${uri.length} characters).`
|
|
461
|
+
: `Asset ${id} ${label}: a sprite strip of ${n} frames, each ${cell}×${cell}, total ${cell * n}×${cell} (${uri.length} characters).`,
|
|
462
|
+
n > 1
|
|
463
|
+
? `Play it with CSS: width:${cell}px;height:${cell}px;background:url(<the URI>) 0 0/auto ${cell}px;` +
|
|
464
|
+
`animation:play ${(n / 8).toFixed(2)}s steps(${n}) infinite; @keyframes play{to{background-position:-${cell * n}px 0}}` +
|
|
465
|
+
" (scale with transform:scale(N) and image-rendering:pixelated)."
|
|
466
|
+
: "Use it as <img src=\"<the URI>\" style=\"image-rendering:pixelated\"> or a CSS background.",
|
|
467
|
+
"",
|
|
468
|
+
uri,
|
|
469
|
+
];
|
|
470
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
|
|
364
474
|
server.tool(
|
|
365
475
|
"get_asset",
|
|
366
476
|
"Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
|