voidless-mcp 1.0.0 → 1.6.2
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 +68 -20
- package/gallery.html +176 -0
- package/index.js +8 -524
- package/package.json +7 -3
- package/tools.js +863 -0
package/tools.js
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voidless MCP tools, shared by the local server (index.js, stdio, VOIDLESS_API_KEY) and the hosted
|
|
3
|
+
* connector (http.js, mcp.voidless.dev, signed in with OAuth). createVoidlessServer(apiKey) builds
|
|
4
|
+
* one MCP server acting as the owner of that void_ key.
|
|
5
|
+
*
|
|
6
|
+
* Exposes the Voidless API as MCP tools so any MCP client (Claude, Cursor, …) can find and tag a
|
|
7
|
+
* user's assets and generate new ones: sprites, edits, 8-way rotations, single animations, whole
|
|
8
|
+
* animation sets from presets (left side mirrored from the right), sound, music, video and rigged
|
|
9
|
+
* 3D models.
|
|
10
|
+
*
|
|
11
|
+
* Request shapes follow the Strapi controllers in strapi/src/api/generate/controllers. Several
|
|
12
|
+
* fields there are tested with `!== ""`, so an omitted field is NOT the same as an empty one:
|
|
13
|
+
* referenceImage/styleImage/end_frame are always sent, empty when unused.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
20
|
+
|
|
21
|
+
// Node's fetch gives up after 5 minutes without response headers. Generation calls answer only
|
|
22
|
+
// when the job is done, and an 8-direction animation set can run longer, so wait as long as the
|
|
23
|
+
// server does.
|
|
24
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
|
|
25
|
+
|
|
26
|
+
export const VERSION = "1.6.2";
|
|
27
|
+
const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
|
|
28
|
+
const CDN_URL = "https://cdn.voidless.dev";
|
|
29
|
+
// Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
|
|
30
|
+
// page plays every animation and holds the rotations, edits and downloads. A local API (our own dev
|
|
31
|
+
// setup) defaults to the local app; VOIDLESS_APP_URL overrides either.
|
|
32
|
+
const APP_URL = (
|
|
33
|
+
process.env.VOIDLESS_APP_URL ||
|
|
34
|
+
(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(BASE_URL) ? "http://localhost:4200" : "https://voidless.dev")
|
|
35
|
+
).replace(/\/$/, "");
|
|
36
|
+
|
|
37
|
+
// Inline previews are still frames (from the middle of a clip, not its GIF): an animation GIF is ~600 KB
|
|
38
|
+
// and most clients show only its first frame anyway. The GIF stays in the text as a link. Capped so a
|
|
39
|
+
// call that returns a whole set does not flood the context; 8 covers one 8-direction set.
|
|
40
|
+
const MAX_INLINE_IMAGES = 8;
|
|
41
|
+
|
|
42
|
+
const GALLERY_URI = "ui://voidless/gallery.html";
|
|
43
|
+
const GALLERY_MIME = "text/html;profile=mcp-app";
|
|
44
|
+
const GALLERY_TOOLS = [
|
|
45
|
+
"generate_image", "upload_image", "edit_image", "rotate_sprite", "animate_sprite", "generate_animation_set",
|
|
46
|
+
"flip_left_side", "generate_video", "generate_sfx", "generate_music", "generate_3d_model", "get_asset",
|
|
47
|
+
"list_assets", "extract_parts", "pixelate_image", "extend_video",
|
|
48
|
+
];
|
|
49
|
+
const GALLERY_HTML = readFileSync(new URL("./gallery.html", import.meta.url), "utf8");
|
|
50
|
+
|
|
51
|
+
export function createVoidlessServer(API_KEY) {
|
|
52
|
+
async function api(method, path, body) {
|
|
53
|
+
const res = await fetch(`${BASE_URL}/api${path}`, {
|
|
54
|
+
method,
|
|
55
|
+
headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
|
|
56
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let data;
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
try {
|
|
62
|
+
data = JSON.parse(text);
|
|
63
|
+
} catch {
|
|
64
|
+
data = { raw: text };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const msg = data?.error?.message || data?.message || data?.raw || `HTTP ${res.status}`;
|
|
69
|
+
throw new Error(`Voidless API error (${res.status}): ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const apiGet = (path) => api("GET", path);
|
|
75
|
+
const apiPost = (path, body) => api("POST", path, body);
|
|
76
|
+
const apiPut = (path, body) => api("PUT", path, body);
|
|
77
|
+
|
|
78
|
+
// Tool errors go back to the model as text it can act on, not as a protocol failure.
|
|
79
|
+
function tool(handler) {
|
|
80
|
+
return async (args) => {
|
|
81
|
+
try {
|
|
82
|
+
return await handler(args);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { isError: true, content: [{ type: "text", text: String(err.message || err) }] };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Formatting
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function mimeFromPath(filePath) {
|
|
94
|
+
const ext = String(filePath || "").split(".").pop().toLowerCase();
|
|
95
|
+
const map = {
|
|
96
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
97
|
+
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", mp4: "video/mp4", webm: "video/webm",
|
|
98
|
+
glb: "model/gltf-binary", gltf: "model/gltf+json",
|
|
99
|
+
};
|
|
100
|
+
return map[ext] || "application/octet-stream";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Stored keys are R2 paths (assets/<uid>/…) or Drive refs (gdrive:…), which have no public URL.
|
|
104
|
+
function cdnUrl(key) {
|
|
105
|
+
if (!key || typeof key !== "string") return null;
|
|
106
|
+
if (/^https?:\/\//.test(key)) return key;
|
|
107
|
+
if (key.startsWith("gdrive:")) return null;
|
|
108
|
+
return `${CDN_URL}/${key.replace(/^\/+/, "")}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The image that shows what a frame is. For an animation that is a frame from the middle: every clip
|
|
112
|
+
// starts (and loops back) on the sprite's resting pose, so its first frame made a dash or an attack
|
|
113
|
+
// look like the character standing still.
|
|
114
|
+
function stillOf(frame) {
|
|
115
|
+
const images = Array.isArray(frame?.images) ? frame.images : [];
|
|
116
|
+
if (!images.length) return frame?.gif;
|
|
117
|
+
return frame.type === "animation" ? images[Math.floor(images.length / 2)] : images[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function inlineImage(key) {
|
|
121
|
+
const url = cdnUrl(key);
|
|
122
|
+
if (!url || !mimeFromPath(url).startsWith("image/")) return null;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(url);
|
|
125
|
+
if (!res.ok) return null;
|
|
126
|
+
const data = Buffer.from(await res.arrayBuffer()).toString("base64");
|
|
127
|
+
return { type: "image", data, mimeType: mimeFromPath(url) };
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function money(data) {
|
|
134
|
+
const parts = [];
|
|
135
|
+
if (typeof data?.cost === "number") parts.push(`Cost: ${data.cost} credits`);
|
|
136
|
+
if (typeof data?.credits === "number") parts.push(`Credits left: ${data.credits}`);
|
|
137
|
+
return parts.join(" · ");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A rotation frame's `tags` field is its facings list ("right,up,left…"), which animation presets
|
|
141
|
+
// read to match each image to a direction. It is not user tagging, and must not be overwritten.
|
|
142
|
+
const isRotation = (frame) => String(frame?.type || "").startsWith("rotations");
|
|
143
|
+
|
|
144
|
+
// One line per frame, carrying everything a follow-up call needs: the frame id (frameIndex /
|
|
145
|
+
// items / frameId elsewhere) and the image keys (referenceFile / end_frame / imageIndex).
|
|
146
|
+
function frameLine(frame) {
|
|
147
|
+
const images = Array.isArray(frame.images) ? frame.images : [];
|
|
148
|
+
const bits = [`frame ${frame.id}`, frame.type || "frame", frame.name ? `"${frame.name}"` : null];
|
|
149
|
+
if (frame.tags) bits.push(`${isRotation(frame) ? "facings" : "tags"}: ${frame.tags}`);
|
|
150
|
+
const imageText =
|
|
151
|
+
images.length <= 8
|
|
152
|
+
? images.map((k, i) => ` [${i}] ${k}`).join("\n")
|
|
153
|
+
: ` ${images.length} images, first ${images[0]}, last ${images[images.length - 1]}`;
|
|
154
|
+
return ` - ${bits.filter(Boolean).join(" · ")}${images.length ? "\n" + imageText : ""}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function frameContent(frames, { inline = true } = {}) {
|
|
158
|
+
const content = [];
|
|
159
|
+
if (!frames?.length) return content;
|
|
160
|
+
content.push({ type: "text", text: "New frames:\n" + frames.map(frameLine).join("\n") });
|
|
161
|
+
if (inline) {
|
|
162
|
+
for (const frame of frames.slice(0, MAX_INLINE_IMAGES)) {
|
|
163
|
+
const image = await inlineImage(stillOf(frame));
|
|
164
|
+
if (image) content.push(image);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return content;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Responses that create a new asset: { file, assetId, credits, cost, … }.
|
|
171
|
+
async function assetResult(data, label) {
|
|
172
|
+
const url = cdnUrl(data.file);
|
|
173
|
+
const lines = [
|
|
174
|
+
`${label} created.`,
|
|
175
|
+
data.assetId ? `Asset ID: ${data.assetId} · ${APP_URL}/asset/${data.assetId}` : null,
|
|
176
|
+
url && !mimeFromPath(url).startsWith("image/") ? `File: ${url}` : null,
|
|
177
|
+
data.thumbnail ? `Thumbnail: ${cdnUrl(data.thumbnail)}` : null,
|
|
178
|
+
data.seed !== undefined ? `Seed: ${data.seed}` : null,
|
|
179
|
+
money(data),
|
|
180
|
+
].filter(Boolean);
|
|
181
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
182
|
+
const image = await inlineImage(data.file);
|
|
183
|
+
if (image) content.push(image);
|
|
184
|
+
const link = data.assetId ? `${APP_URL}/asset/${data.assetId}` : undefined;
|
|
185
|
+
const main = mediaItem(data.file, label, { link });
|
|
186
|
+
const thumb = !main || main.kind === "audio" ? mediaItem(data.thumbnail, `${label} preview`, { link }) : null;
|
|
187
|
+
return withGallery({ content }, [main, thumb], label);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Responses that append frames to an existing asset: { frames, credits, cost }.
|
|
191
|
+
async function framesResult(data, assetId, label) {
|
|
192
|
+
const content = [
|
|
193
|
+
{
|
|
194
|
+
type: "text",
|
|
195
|
+
text: [`${label} added to asset ${assetId} (${APP_URL}/asset/${assetId}).`, money(data)]
|
|
196
|
+
.filter(Boolean)
|
|
197
|
+
.join("\n"),
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
content.push(...(await frameContent(data.frames)));
|
|
201
|
+
if (Array.isArray(data.missing) && data.missing.length) {
|
|
202
|
+
content.push({ type: "text", text: `Skipped (could not be edited): ${JSON.stringify(data.missing)}` });
|
|
203
|
+
}
|
|
204
|
+
return withGallery({ content }, (data.frames || []).flatMap((f) => frameItems(f, assetId)), label);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Multi-animation responses: { results: [{ assetId, frames, skipped? }], quality, cost, … }.
|
|
208
|
+
async function resultsContent(data, label) {
|
|
209
|
+
const content = [
|
|
210
|
+
{
|
|
211
|
+
type: "text",
|
|
212
|
+
text: [label, data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
let inlined = 0;
|
|
216
|
+
for (const result of data.results || []) {
|
|
217
|
+
if (result.skipped) {
|
|
218
|
+
content.push({ type: "text", text: `Asset ${result.assetId}: skipped${result.error ? ` (${result.error})` : ""}` });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
content.push({
|
|
222
|
+
type: "text",
|
|
223
|
+
text: `Asset ${result.assetId} (${APP_URL}/asset/${result.assetId}):\n` + (result.frames || []).map(frameLine).join("\n"),
|
|
224
|
+
});
|
|
225
|
+
for (const frame of result.frames || []) {
|
|
226
|
+
if (inlined >= MAX_INLINE_IMAGES) break;
|
|
227
|
+
const image = await inlineImage(stillOf(frame));
|
|
228
|
+
if (image) {
|
|
229
|
+
content.push(image);
|
|
230
|
+
inlined++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const items = (data.results || []).filter((r) => !r.skipped).flatMap((r) => (r.frames || []).flatMap((f) => frameItems(f, r.assetId)));
|
|
235
|
+
return withGallery({ content }, items, label);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Gallery (MCP Apps): what the inline viewer shows. Hosts that support MCP Apps (claude.ai, the
|
|
240
|
+
// Claude apps, ChatGPT) draw ui://voidless/gallery.html under the tool call and hand it the
|
|
241
|
+
// result's structuredContent.gallery; others just read the text and the inline images.
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
function mediaKind(url) {
|
|
244
|
+
const m = mimeFromPath(url);
|
|
245
|
+
if (m === "image/gif") return "gif";
|
|
246
|
+
if (m.startsWith("image/")) return "image";
|
|
247
|
+
if (m.startsWith("video/")) return "video";
|
|
248
|
+
if (m.startsWith("audio/")) return "audio";
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
function mediaItem(key, label, extra = {}) {
|
|
252
|
+
const url = cdnUrl(key);
|
|
253
|
+
const kind = url && mediaKind(url);
|
|
254
|
+
return kind ? { kind, url, label: label || "", ...extra } : null;
|
|
255
|
+
}
|
|
256
|
+
// One frame record -> what to show: an animation plays (its GIF), a rotation set shows every
|
|
257
|
+
// facing, anything else its image.
|
|
258
|
+
function frameItems(frame, assetId) {
|
|
259
|
+
const link = assetId ? `${APP_URL}/asset/${assetId}` : undefined;
|
|
260
|
+
const name = frame.name || frame.type || "frame";
|
|
261
|
+
const images = Array.isArray(frame.images) ? frame.images : [];
|
|
262
|
+
if (frame.type === "animation") {
|
|
263
|
+
// The viewer plays the frames itself (not every animation has a GIF stored); the still is the
|
|
264
|
+
// poster until they load. Capped so a long clip doesn't bloat the result.
|
|
265
|
+
const sequence = images.map(cdnUrl).filter(Boolean).slice(0, 64);
|
|
266
|
+
const item = mediaItem(stillOf(frame) || frame.gif, name, { link, frames: images.length });
|
|
267
|
+
if (item && sequence.length > 1) { item.kind = "animation"; item.sequence = sequence; }
|
|
268
|
+
return item ? [item] : [];
|
|
269
|
+
}
|
|
270
|
+
if (isRotation(frame)) {
|
|
271
|
+
const facings = String(frame.tags || "").split(",").map((t) => t.trim());
|
|
272
|
+
return images.map((k, i) => mediaItem(k, facings[i] || `${name} ${i + 1}`, { link })).filter(Boolean);
|
|
273
|
+
}
|
|
274
|
+
const item = mediaItem(images[0] || frame.gif, name, { link });
|
|
275
|
+
return item ? [item] : [];
|
|
276
|
+
}
|
|
277
|
+
const MAX_GALLERY = 48;
|
|
278
|
+
const withGallery = (result, items, title) => ({
|
|
279
|
+
...result,
|
|
280
|
+
structuredContent: { title, gallery: items.filter(Boolean).slice(0, MAX_GALLERY) },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Read by clients as the server's standing instructions (MCP `instructions`).
|
|
284
|
+
const INSTRUCTIONS =
|
|
285
|
+
"Every generation tool spends the user's Voidless credits. Make each request once: show the user the result " +
|
|
286
|
+
"(the gallery displays it) and let them decide. Do not call a generation tool again to try for a better result, " +
|
|
287
|
+
"to fix a detail, or because the result is not exactly what you pictured — ask the user first, saying what it " +
|
|
288
|
+
"would cost. Re-run only on a clear failure the tool reports (an error), or when the user asks. " +
|
|
289
|
+
"For a character that will be animated, generate it in the 'idle' pose; 'tpose' is only for rigging.";
|
|
290
|
+
const server = new McpServer({ name: "voidless", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
291
|
+
|
|
292
|
+
// Tool annotations (MCP ToolAnnotations). Generation tools create something new in the caller's
|
|
293
|
+
// Voidless library and spend credits: none deletes or overwrites (edits append new frames; the
|
|
294
|
+
// source is kept), a repeat call makes another result and bills again, and the effects stay
|
|
295
|
+
// inside the user's own account. Directories such as Claude's require title +
|
|
296
|
+
// readOnlyHint/destructiveHint on every tool.
|
|
297
|
+
function writeTool(title) {
|
|
298
|
+
return { title, readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
299
|
+
}
|
|
300
|
+
function readTool(title) {
|
|
301
|
+
return { title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
302
|
+
}
|
|
303
|
+
// Tagging changes labels only (no credits), but replace/remove drop existing tags.
|
|
304
|
+
function tagTool(title) {
|
|
305
|
+
return { title, readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const assetId = z.number().int().describe("Asset ID (from list_assets / get_asset or a previous result)");
|
|
309
|
+
const referenceFile = z
|
|
310
|
+
.string()
|
|
311
|
+
.default("")
|
|
312
|
+
.describe(
|
|
313
|
+
"Work from one image of an existing frame instead of the asset's base: pass an image key exactly as " +
|
|
314
|
+
"get_asset lists it. Empty = the asset's current base image."
|
|
315
|
+
);
|
|
316
|
+
const frameId = z
|
|
317
|
+
.number()
|
|
318
|
+
.int()
|
|
319
|
+
.optional()
|
|
320
|
+
.describe("Optional: the frame the new frames hang under in the editor (usually the frame referenceFile came from).");
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Reading the library
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
server.tool(
|
|
327
|
+
"list_assets",
|
|
328
|
+
"List the user's Voidless assets, newest first: id, type, prompt, tags and how many frames/animations " +
|
|
329
|
+
"each has. Use this to find an asset to animate, rotate or edit. Free.",
|
|
330
|
+
{
|
|
331
|
+
type: z.enum(["image", "sfx", "music", "video", "model"]).optional().describe("Only this asset type"),
|
|
332
|
+
tag: z.string().optional().describe("Only assets whose tags contain this text"),
|
|
333
|
+
page: z.number().int().min(1).default(1),
|
|
334
|
+
pageSize: z.number().int().min(1).max(100).default(25),
|
|
335
|
+
},
|
|
336
|
+
readTool("List assets"),
|
|
337
|
+
tool(async ({ type, tag, page, pageSize }) => {
|
|
338
|
+
const q = [];
|
|
339
|
+
if (type) q.push(`filters[$and][0][type]=${type}`);
|
|
340
|
+
if (tag) q.push(`filters[tags][$contains]=${encodeURIComponent(tag)}`);
|
|
341
|
+
q.push("sort=createdAt:desc", `pagination[page]=${page}`, `pagination[pageSize]=${pageSize}`);
|
|
342
|
+
const data = await apiGet(`/assets?${q.join("&")}`);
|
|
343
|
+
const rows = (data.data || []).map((item) => {
|
|
344
|
+
const a = item.attributes || item;
|
|
345
|
+
const frames = (a.meta_data?.frames || []).filter((f) => !f.archived);
|
|
346
|
+
const animations = frames.filter((f) => f.type === "animation").length;
|
|
347
|
+
const kind = a.meta_data?.image_type ? `${a.type}/${a.meta_data.image_type}` : a.type;
|
|
348
|
+
const extra = frames.length ? ` · ${frames.length} frames (${animations} animations)` : "";
|
|
349
|
+
const tags = a.tags ? ` · tags: ${a.tags}` : "";
|
|
350
|
+
return `- ${item.id} · ${kind} · "${String(a.prompt || "").slice(0, 80)}"${tags}${extra}`;
|
|
351
|
+
});
|
|
352
|
+
const p = data.meta?.pagination;
|
|
353
|
+
const footer = p ? `\nPage ${p.page} of ${p.pageCount} (${p.total} assets)` : "";
|
|
354
|
+
const items = (data.data || []).map((item) => {
|
|
355
|
+
const a = item.attributes || item;
|
|
356
|
+
const label = `${item.id} · ${String(a.prompt || a.type || "").slice(0, 40)}`;
|
|
357
|
+
const link = `${APP_URL}/asset/${item.id}`;
|
|
358
|
+
return mediaItem(a.file, label, { link }) || mediaItem(a.meta_data?.thumbnail, label, { link });
|
|
359
|
+
});
|
|
360
|
+
return withGallery({ content: [{ type: "text", text: (rows.join("\n") || "No assets found.") + footer }] }, items, "Your assets");
|
|
361
|
+
})
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
server.tool(
|
|
365
|
+
"get_asset",
|
|
366
|
+
"Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
|
|
367
|
+
"names, facings and image keys. Frame ids are what frameIndex/items/frameId take elsewhere; image keys " +
|
|
368
|
+
"are what referenceFile and end_frame take. Free.",
|
|
369
|
+
{ id: assetId, preview: z.boolean().default(true).describe("Include the base image inline") },
|
|
370
|
+
readTool("Get asset"),
|
|
371
|
+
tool(async ({ id, preview }) => {
|
|
372
|
+
const a = await apiGet(`/assets/${id}`);
|
|
373
|
+
const meta = a.meta_data || {};
|
|
374
|
+
const frames = (meta.frames || []).filter((f) => !f.archived);
|
|
375
|
+
const lines = [
|
|
376
|
+
`Asset ${a.id} · ${a.type}${meta.image_type ? "/" + meta.image_type : ""} · ${APP_URL}/asset/${a.id}`,
|
|
377
|
+
a.prompt ? `Prompt: ${a.prompt}` : null,
|
|
378
|
+
`Tags: ${a.tags || "(none)"}`,
|
|
379
|
+
meta.size ? `Size: ${meta.size}px · ${meta.dimension || ""} · style ${meta.style || "-"} · facing ${meta.facing || "-"}` : null,
|
|
380
|
+
a.file ? `Base image: ${a.file}` : null,
|
|
381
|
+
meta.rigged_file ? `Rigged: yes (${(meta.animations || []).length} animations)` : null,
|
|
382
|
+
Array.isArray(meta.video_extensions) && meta.video_extensions.length
|
|
383
|
+
? `Video extensions: ${meta.video_extensions.map((v) => v.file).join(", ")}`
|
|
384
|
+
: null,
|
|
385
|
+
frames.length ? `Frames (${frames.length}):\n${frames.map(frameLine).join("\n")}` : "No frames yet.",
|
|
386
|
+
].filter(Boolean);
|
|
387
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
388
|
+
if (preview) {
|
|
389
|
+
const image = await inlineImage(a.file);
|
|
390
|
+
if (image) content.push(image);
|
|
391
|
+
}
|
|
392
|
+
const link = `${APP_URL}/asset/${a.id}`;
|
|
393
|
+
const items = [mediaItem(a.file, a.prompt ? a.prompt.slice(0, 60) : `Asset ${a.id}`, { link }), ...frames.flatMap((f) => frameItems(f, a.id))];
|
|
394
|
+
return withGallery({ content }, items, `Asset ${a.id}`);
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
server.tool(
|
|
399
|
+
"get_animation_presets",
|
|
400
|
+
"List the animation-set presets (platformer-character, topdown-walk/run/dash/attack/idle) with the " +
|
|
401
|
+
"actions each one makes, and the quality tiers with their credit cost per pass. Free.",
|
|
402
|
+
{},
|
|
403
|
+
readTool("Get animation presets"),
|
|
404
|
+
tool(async () => {
|
|
405
|
+
const data = await apiGet("/generate/multi-animation/presets");
|
|
406
|
+
const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
|
|
407
|
+
const qualities = (data.qualities || []).map((q) => `- ${q.key}: ${q.resolution}, ${q.credits} credits per pass`);
|
|
408
|
+
return {
|
|
409
|
+
content: [
|
|
410
|
+
{
|
|
411
|
+
type: "text",
|
|
412
|
+
text: `Presets:\n${presets.join("\n")}\n\nQuality (picked automatically from the sprite size):\n${qualities.join("\n")}\n\nUp to ${data.maxImages} images per run.`,
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Tagging
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
const tagList = z
|
|
424
|
+
.array(z.string().min(1).max(40))
|
|
425
|
+
.min(1)
|
|
426
|
+
.describe("Tags, e.g. ['player', 'level-1']. Case-insensitive duplicates are merged");
|
|
427
|
+
const tagMode = z
|
|
428
|
+
.enum(["add", "remove", "replace"])
|
|
429
|
+
.default("add")
|
|
430
|
+
.describe("add = keep existing tags and add these, remove = drop these, replace = set exactly these");
|
|
431
|
+
|
|
432
|
+
function applyTags(current, tags, mode) {
|
|
433
|
+
const existing = String(current || "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
434
|
+
const lower = new Set(tags.map((t) => t.toLowerCase()));
|
|
435
|
+
const merged = mode === "replace" ? tags : mode === "remove" ? existing.filter((t) => !lower.has(t.toLowerCase())) : [...existing, ...tags];
|
|
436
|
+
// Same rule as the server's normalizeTags: case-insensitive duplicates keep the first spelling.
|
|
437
|
+
const seen = new Map();
|
|
438
|
+
for (const t of merged) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t);
|
|
439
|
+
return [...seen.values()];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
server.tool(
|
|
443
|
+
"tag_asset",
|
|
444
|
+
"Add, remove or replace the tags on an asset, so it can be found again with list_assets(tag). Free.",
|
|
445
|
+
{ id: assetId, tags: tagList, mode: tagMode },
|
|
446
|
+
tagTool("Tag asset"),
|
|
447
|
+
tool(async ({ id, tags, mode }) => {
|
|
448
|
+
const asset = await apiGet(`/assets/${id}`);
|
|
449
|
+
const next = applyTags(asset.tags, tags, mode).join(",");
|
|
450
|
+
const data = await apiPut(`/assets/${id}`, { data: { tags: next } });
|
|
451
|
+
const saved = (data.data?.attributes || data.data || data).tags ?? next;
|
|
452
|
+
return { content: [{ type: "text", text: `Asset ${id} tags: ${saved || "(none)"}` }] };
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
server.tool(
|
|
457
|
+
"tag_frame",
|
|
458
|
+
"Add, remove or replace the tags on one frame of an image asset (an animation, edit or part), e.g. " +
|
|
459
|
+
"to mark which clip is 'idle' or 'final'. 8-way rotation frames cannot be tagged: their tags are the " +
|
|
460
|
+
"facings the animation presets rely on. Free.",
|
|
461
|
+
{
|
|
462
|
+
id: assetId,
|
|
463
|
+
frameIndex: z.number().int().describe("Frame id from get_asset"),
|
|
464
|
+
tags: tagList,
|
|
465
|
+
mode: tagMode,
|
|
466
|
+
},
|
|
467
|
+
tagTool("Tag frame"),
|
|
468
|
+
tool(async ({ id, frameIndex, tags, mode }) => {
|
|
469
|
+
const asset = await apiGet(`/assets/${id}`);
|
|
470
|
+
const frame = (asset.meta_data?.frames || []).find((f) => f.id === frameIndex && !f.archived);
|
|
471
|
+
if (!frame) throw new Error(`Asset ${id} has no frame ${frameIndex}. Use get_asset ${id} for its frame ids.`);
|
|
472
|
+
if (isRotation(frame)) {
|
|
473
|
+
throw new Error(`Frame ${frameIndex} is an 8-way rotation: its tags are its facings, which animation presets read. Tag the asset instead.`);
|
|
474
|
+
}
|
|
475
|
+
if (!frame.images?.length) throw new Error(`Frame ${frameIndex} has no images to locate it by.`);
|
|
476
|
+
const next = applyTags(frame.tags, tags, mode).join(",");
|
|
477
|
+
// The server finds the frame by one of its image files, which stays stable across reindexing.
|
|
478
|
+
const data = await apiPut(`/assets/${id}/frame-tags`, { referenceFile: frame.images[0], tags: next });
|
|
479
|
+
return { content: [{ type: "text", text: `Frame ${frameIndex} of asset ${id} tags: ${data.tags || "(none)"}` }] };
|
|
480
|
+
})
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Images
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
server.tool(
|
|
488
|
+
"generate_image",
|
|
489
|
+
"Generate a game sprite/asset image. Infer every field from the request: art STYLE, colour PALETTE, " +
|
|
490
|
+
"asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Use the default model " +
|
|
491
|
+
"(flux2-klein, 1 credit) unless the user names another. Costs by model: flux2-klein 1, flux-juggernaut 1, " +
|
|
492
|
+
"seedream-5-flash 2, gpt-image-2.5 5, retro-diffusion 10, nano-banana-2 15 credits (a reference image makes it " +
|
|
493
|
+
"5 and uses flux2-klein). Call it once per request and " +
|
|
494
|
+
"show the result; ask the user before generating again (each call spends credits).",
|
|
495
|
+
{
|
|
496
|
+
prompt: z.string().max(300).describe("The subject only (max 300 chars). Put style/colour in the style/palette fields."),
|
|
497
|
+
model: z
|
|
498
|
+
.enum(["flux2-klein", "gpt-image-2.5", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
|
|
499
|
+
.default("flux2-klein")
|
|
500
|
+
.describe("The default, flux2-klein, unless the user asks for a specific model by name."),
|
|
501
|
+
type: z
|
|
502
|
+
.enum(["object", "character", "character portrait", "artwork", "tileset", "ui"])
|
|
503
|
+
.describe(
|
|
504
|
+
"'character' = full-body sprite (uses facing/pose/bodyType); 'character portrait' = headshot; 'object' = props " +
|
|
505
|
+
"and items; 'artwork' = scenes/backgrounds; 'tileset' = map tiles; 'ui' = interface pieces (see uiMode)."
|
|
506
|
+
),
|
|
507
|
+
dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
|
|
508
|
+
size: z
|
|
509
|
+
.union([z.literal(32), z.literal(64), z.literal(128), z.literal(512), z.literal(1024)])
|
|
510
|
+
.default(512)
|
|
511
|
+
.describe("Output px. 32/64/128 give a pixel-art look; 512 for every other style; 1024 for high-res."),
|
|
512
|
+
colours: z.number().int().min(4).max(12).default(8).describe("Colour count for pixel-art reduction (4–12)"),
|
|
513
|
+
style: z
|
|
514
|
+
.string()
|
|
515
|
+
.default("")
|
|
516
|
+
.describe(
|
|
517
|
+
"Style preset: pixel-art, cartoon, cel-shading, flat, hand-drawn, paper-cutout, comic-book, anime, watercolor, " +
|
|
518
|
+
"low-poly, 3d-render, claymation, photorealistic, voxel. Empty = pixel art below 512, cartoon otherwise."
|
|
519
|
+
),
|
|
520
|
+
palette: z
|
|
521
|
+
.enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia"])
|
|
522
|
+
.default("none")
|
|
523
|
+
.describe("Colour-scheme preset, independent of style. 'none' keeps the style's own colours."),
|
|
524
|
+
facing: z.enum(["forward", "right"]).default("forward").describe("Characters: front view or side profile. Side profiles rotate best."),
|
|
525
|
+
pose: z
|
|
526
|
+
.enum(["idle", "tpose"])
|
|
527
|
+
.default("idle")
|
|
528
|
+
.describe(
|
|
529
|
+
"Characters, front or side view: 'idle' = relaxed stance with the arms down — the default, for animation " +
|
|
530
|
+
"sets, rotations and almost everything. 'tpose' = arms held straight out — ONLY when the user asks for a " +
|
|
531
|
+
"T-pose (e.g. to rig the character in the skeleton editor)."
|
|
532
|
+
),
|
|
533
|
+
bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters: body plan"),
|
|
534
|
+
uiMode: z
|
|
535
|
+
.enum(["graphic", "title", "text"])
|
|
536
|
+
.default("graphic")
|
|
537
|
+
.describe("type 'ui' only: 'graphic' = button/panel/HUD piece, 'title' = game-title wordmark spelling the prompt, 'text' = plain label spelling the prompt"),
|
|
538
|
+
seed: z.number().int().min(0).default(0).describe("RNG seed (0 = random)"),
|
|
539
|
+
referenceImage: z.string().default("").describe("Optional raw base64 image (no data: prefix) to base the result on"),
|
|
540
|
+
styleImage: z.string().default("").describe("Optional raw base64 image whose art style to copy"),
|
|
541
|
+
},
|
|
542
|
+
writeTool("Generate image"),
|
|
543
|
+
tool(async ({ facing, pose, bodyType, palette, uiMode, ...rest }) => {
|
|
544
|
+
const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode: uiMode } };
|
|
545
|
+
return assetResult(await apiPost("/generate/image", body), "Image");
|
|
546
|
+
})
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
server.tool(
|
|
550
|
+
"edit_image",
|
|
551
|
+
"Edit an existing image asset with an instruction (change colours, add a hat, fix a detail…). The edit is " +
|
|
552
|
+
"added as a new frame; the original stays. 5 credits.",
|
|
553
|
+
{
|
|
554
|
+
id: assetId,
|
|
555
|
+
prompt: z.string().describe("What to change"),
|
|
556
|
+
referenceFile,
|
|
557
|
+
frameId,
|
|
558
|
+
images: z.array(z.string()).max(4).default([]).describe("Optional extra reference images: image keys from get_asset or https URLs"),
|
|
559
|
+
remove_bg: z.boolean().default(false).describe("Remove the background from the result"),
|
|
560
|
+
},
|
|
561
|
+
writeTool("Edit image"),
|
|
562
|
+
// Always the app's own editor, the same one as the editor's "Edit this image" box.
|
|
563
|
+
tool(async (args) => framesResult(await apiPost("/generate/image-edit", { ...args, model: "p-image" }), args.id, "Edit"))
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
server.tool(
|
|
567
|
+
"rotate_sprite",
|
|
568
|
+
"Turn a sprite into an 8-way set (front, back, sides and diagonals) in one pass. The result is a " +
|
|
569
|
+
"'rotations - 8-way' frame whose facings line up with its images; pass those to generate_animation_set " +
|
|
570
|
+
"to animate each direction. Works best from a side-profile sprite. 10 credits.",
|
|
571
|
+
{ id: assetId, referenceFile, frameId },
|
|
572
|
+
writeTool("Rotate sprite (8-way)"),
|
|
573
|
+
tool(async (args) => framesResult(await apiPost("/generate/image-rotations", args), args.id, "8-way rotation"))
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
server.tool(
|
|
577
|
+
"extract_parts",
|
|
578
|
+
"Split a character sprite into separate body parts (head, torso, arms, legs…) as 'layer' frames, for " +
|
|
579
|
+
"rigging or cut-out animation. 5 credits. Call get_asset afterwards for the parts' frame ids.",
|
|
580
|
+
{
|
|
581
|
+
id: assetId,
|
|
582
|
+
prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = the standard rig parts"),
|
|
583
|
+
referenceFile,
|
|
584
|
+
frameId,
|
|
585
|
+
},
|
|
586
|
+
writeTool("Extract sprite parts"),
|
|
587
|
+
tool(async (args) => {
|
|
588
|
+
const data = await apiPost("/generate/image-parts", args);
|
|
589
|
+
// The ids returned here are off by (parts - 1): the server numbers from the last appended
|
|
590
|
+
// frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
|
|
591
|
+
const names = (data.frames || []).map((f) => f.name).filter(Boolean);
|
|
592
|
+
return {
|
|
593
|
+
content: [
|
|
594
|
+
{
|
|
595
|
+
type: "text",
|
|
596
|
+
text: `Extracted ${names.length} parts from asset ${args.id}: ${names.join(", ")}.\n${money(data)}\nUse get_asset ${args.id} for their frame ids and image keys.`,
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
structuredContent: { title: "Parts", gallery: (data.frames || []).flatMap((f) => frameItems(f, args.id)).slice(0, MAX_GALLERY) },
|
|
600
|
+
};
|
|
601
|
+
})
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
server.tool(
|
|
605
|
+
"upload_image",
|
|
606
|
+
"Add an image the user already has to their library, so it can be animated, rotated or edited. It must " +
|
|
607
|
+
"be square at 32, 64, 128, 512 or 1024 px. Free.",
|
|
608
|
+
{
|
|
609
|
+
image: z.string().describe("Base64 image (a data: prefix is stripped automatically)"),
|
|
610
|
+
type: z.enum(["object", "character", "character portrait", "artwork"]).default("character"),
|
|
611
|
+
remove_bg: z.boolean().default(false).describe("Remove the background on upload"),
|
|
612
|
+
},
|
|
613
|
+
writeTool("Upload image"),
|
|
614
|
+
tool(async ({ image, ...rest }) => {
|
|
615
|
+
const raw = String(image).replace(/^data:[^,]*,/, "");
|
|
616
|
+
return assetResult(await apiPost("/upload/image", { image: raw, ...rest }), "Upload");
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
server.tool(
|
|
621
|
+
"pixelate_image",
|
|
622
|
+
"Convert any image to pixel art at a target size and colour count. Returns the image; nothing is " +
|
|
623
|
+
"saved to the library. 1 credit.",
|
|
624
|
+
{
|
|
625
|
+
image: z.string().describe("Base64 source image (a data: prefix is stripped automatically)"),
|
|
626
|
+
size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(512)]).default(64),
|
|
627
|
+
colours: z.number().int().min(4).max(12).default(8),
|
|
628
|
+
},
|
|
629
|
+
writeTool("Pixelate image"),
|
|
630
|
+
tool(async ({ image, ...rest }) => {
|
|
631
|
+
const data = await apiPost("/pixelate/image", { image: String(image).replace(/^data:[^,]*,/, ""), ...rest });
|
|
632
|
+
return {
|
|
633
|
+
content: [
|
|
634
|
+
{ type: "text", text: money(data) || "Pixelated." },
|
|
635
|
+
{ type: "image", data: data.image, mimeType: "image/png" },
|
|
636
|
+
],
|
|
637
|
+
structuredContent: { title: "Pixel art", gallery: data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [] },
|
|
638
|
+
};
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Animation
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
server.tool(
|
|
647
|
+
"animate_sprite",
|
|
648
|
+
"Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
|
|
649
|
+
"(walk + run + attack, or every direction) use generate_animation_set instead: it is one job. 15 credits.",
|
|
650
|
+
{
|
|
651
|
+
id: assetId,
|
|
652
|
+
prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
|
|
653
|
+
referenceFile,
|
|
654
|
+
end_frame: z.string().default("").describe("Optional image key for the clip to end on (e.g. to loop back to a pose). Empty = free ending"),
|
|
655
|
+
frameId,
|
|
656
|
+
},
|
|
657
|
+
writeTool("Animate sprite"),
|
|
658
|
+
tool(async (args) => framesResult(await apiPost("/generate/image-animation", { ...args, mode: "f" }), args.id, "Animation"))
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const pick = z.object({
|
|
662
|
+
assetId: z.number().int().describe("Asset to animate"),
|
|
663
|
+
frameIndex: z.number().int().nullable().optional().describe("Frame id from get_asset (e.g. an 8-way rotation frame). Omit for the asset's base image"),
|
|
664
|
+
imageIndex: z.number().int().optional().describe("Which image of that frame (for a rotation frame: the facing's position in its facings list)"),
|
|
665
|
+
base: z.boolean().optional().describe("true = the asset's original base image, counted as the 'down' (front) facing. Only for sprites drawn facing the camera"),
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
server.tool(
|
|
669
|
+
"generate_animation_set",
|
|
670
|
+
"Generate several animations in one job. Two ways to use it:\n" +
|
|
671
|
+
"1. A PRESET on one sprite: platformer-character (idle, run, jump, attack-1, death, hurt) or a top-down " +
|
|
672
|
+
"set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Each item gets the preset's actions.\n" +
|
|
673
|
+
"2. A PRESET across an 8-way rotation set: one item per facing, {assetId, frameIndex:<rotation frame id>, " +
|
|
674
|
+
"imageIndex:i} where i is the facing's position in the frame's facings list, and every facing gets its own " +
|
|
675
|
+
"directional clip (walk-left from the left-facing image, …). If the facings list has no 'down' (the sprite " +
|
|
676
|
+
"was drawn facing the camera), add {assetId, base:true} as 'down'. If it already has 'down' (a side-view " +
|
|
677
|
+
"sprite), use only the rotation's own images: the base is the side view, not the front. For topdown-idle " +
|
|
678
|
+
"pick only the 4 cardinals (down, right, up, left).\n" +
|
|
679
|
+
"Without a preset, describe the actions in the prompt ('idle, run, jump') for up to 6 custom clips. " +
|
|
680
|
+
"Cost: 100 credits per pass (200 for 1024px sprites, which run in 4K); a pass holds up to 6 clips. On an " +
|
|
681
|
+
"8-way rotation set the left, up-left and down-left clips are mirrored from the right-side ones (the " +
|
|
682
|
+
"rotation's left side is a mirror already), so an 8-way set is ONE pass; set flipFacings false to animate " +
|
|
683
|
+
"all eight (2 passes). Clips are added to their source asset as frames named after the action.",
|
|
684
|
+
{
|
|
685
|
+
items: z.array(pick).min(1).max(12).describe("Images to animate, in order"),
|
|
686
|
+
preset: z
|
|
687
|
+
.enum(["platformer-character", "topdown-walk", "topdown-run", "topdown-dash", "topdown-attack", "topdown-idle"])
|
|
688
|
+
.optional()
|
|
689
|
+
.describe("Named set of actions (see get_animation_presets)"),
|
|
690
|
+
prompt: z.string().default("").describe("Without a preset: the actions to make. With one: optional extra direction for every clip"),
|
|
691
|
+
holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, so directional loops do not drift"),
|
|
692
|
+
flipFacings: z.boolean().default(true).describe("On an 8-way rotation set, mirror the left-side clips from the right-side ones instead of animating them (half the price). One-sided details swap sides, as on the rotation images"),
|
|
693
|
+
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"),
|
|
694
|
+
},
|
|
695
|
+
writeTool("Generate animation set"),
|
|
696
|
+
tool(async (args) => resultsContent(await apiPost("/generate/multi-animation", args), "Animation set generated."))
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
server.tool(
|
|
700
|
+
"flip_left_side",
|
|
701
|
+
"Mirror every right, up-right and down-right animation on an asset that has no left twin into one " +
|
|
702
|
+
"(walk-right -> walk-left). The 8-way turns draw the left side as a mirror of the right, so animate " +
|
|
703
|
+
"the right-facing turns and flip for the rest. Free (no model call). One-sided details swap sides.",
|
|
704
|
+
{ id: assetId },
|
|
705
|
+
writeTool("Flip to left side"),
|
|
706
|
+
tool(async ({ id }) => framesResult(await apiPost("/generate/flip", { id }), id, "Flip"))
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Audio
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
server.tool(
|
|
714
|
+
"generate_sfx",
|
|
715
|
+
"Generate a short sound effect (coin pickup, sword hit, footstep…). 5 credits.",
|
|
716
|
+
{ prompt: z.string().describe("The sound to make") },
|
|
717
|
+
writeTool("Generate sound effect"),
|
|
718
|
+
tool(async ({ prompt }) => assetResult(await apiPost("/generate/sfx", { prompt }), "Sound effect"))
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
server.tool(
|
|
722
|
+
"generate_music",
|
|
723
|
+
"Generate a music track. 10 credits.",
|
|
724
|
+
{
|
|
725
|
+
prompt: z.string().min(10).max(300).describe("Genre, mood and instruments, e.g. 'upbeat chiptune for a title screen' (10–300 chars)"),
|
|
726
|
+
duration: z.number().int().min(30).max(120).default(60).describe("Seconds (30–120)"),
|
|
727
|
+
bpm: z.number().int().min(80).max(200).default(100),
|
|
728
|
+
keyScale: z.string().default("C major").describe("e.g. 'A minor', 'D major'"),
|
|
729
|
+
},
|
|
730
|
+
writeTool("Generate music"),
|
|
731
|
+
tool(async (args) => assetResult(await apiPost("/generate/music", args), "Music"))
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Video
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
server.tool(
|
|
739
|
+
"generate_video",
|
|
740
|
+
"Generate a short video clip (trailer shot, cutscene, animated background) from a prompt and 1–7 " +
|
|
741
|
+
"reference images. 15 credits per second.",
|
|
742
|
+
{
|
|
743
|
+
prompt: z.string().describe("What happens in the clip"),
|
|
744
|
+
images: z.array(z.string()).min(1).max(7).describe("Reference images: image keys from get_asset, https URLs, or data: URIs"),
|
|
745
|
+
duration: z.number().int().min(4).max(15).default(4).describe("Seconds (4–15)"),
|
|
746
|
+
width: z.number().int().default(1280),
|
|
747
|
+
height: z.number().int().default(720),
|
|
748
|
+
},
|
|
749
|
+
writeTool("Generate video"),
|
|
750
|
+
tool(async (args) => assetResult(await apiPost("/generate/video", args), "Video"))
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
server.tool(
|
|
754
|
+
"extend_video",
|
|
755
|
+
"Continue an existing video asset from its last frame. 15 credits per second.",
|
|
756
|
+
{
|
|
757
|
+
assetId: z.number().int().describe("The video asset"),
|
|
758
|
+
prompt: z.string().describe("What happens next"),
|
|
759
|
+
video: z.string().default("").describe("Which clip to continue (a file key from get_asset). Empty = the latest extension, else the original"),
|
|
760
|
+
duration: z.number().int().min(4).max(15).default(4),
|
|
761
|
+
width: z.number().int().default(1280),
|
|
762
|
+
height: z.number().int().default(720),
|
|
763
|
+
},
|
|
764
|
+
writeTool("Extend video"),
|
|
765
|
+
tool(async ({ video, ...args }) => {
|
|
766
|
+
let source = video;
|
|
767
|
+
if (!source) {
|
|
768
|
+
const asset = await apiGet(`/assets/${args.assetId}`);
|
|
769
|
+
const extensions = asset.meta_data?.video_extensions || [];
|
|
770
|
+
source = extensions.length ? extensions[extensions.length - 1].file : asset.file;
|
|
771
|
+
}
|
|
772
|
+
const data = await apiPost("/generate/video-extend", { ...args, video: source, images: [] });
|
|
773
|
+
const clips = (data.videos || []).map((v) => `- ${cdnUrl(v.file)}`).join("\n");
|
|
774
|
+
const link = `${APP_URL}/asset/${args.assetId}`;
|
|
775
|
+
return withGallery(
|
|
776
|
+
{ content: [{ type: "text", text: `Extended video ${args.assetId}:\n${clips}\n${money(data)}` }] },
|
|
777
|
+
(data.videos || []).map((v, i) => mediaItem(v.file, `Extension ${i + 1}`, { link })),
|
|
778
|
+
"Video extended"
|
|
779
|
+
);
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// 3D
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
server.tool(
|
|
788
|
+
"generate_3d_model",
|
|
789
|
+
"Generate a 3D model (GLB) from a prompt. 15 credits (lq) or 25 (hq).",
|
|
790
|
+
{
|
|
791
|
+
prompt: z.string().describe("The model to make"),
|
|
792
|
+
type: z.enum(["object", "character"]).default("object").describe("'character' makes a T-posed figure ready for rigging"),
|
|
793
|
+
style: z.string().default("cartoon style").describe("e.g. 'cartoon style', 'low-poly', 'realistic'"),
|
|
794
|
+
mode: z.enum(["lq", "hq"]).default("hq").describe("lq = fast draft, hq = detailed"),
|
|
795
|
+
bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters only"),
|
|
796
|
+
seed: z.number().int().min(0).default(0),
|
|
797
|
+
},
|
|
798
|
+
writeTool("Generate 3D model"),
|
|
799
|
+
tool(async ({ bodyType, ...rest }) => {
|
|
800
|
+
const options = rest.type === "character" ? { pose: "tpose", bodyType } : {};
|
|
801
|
+
return assetResult(await apiPost("/generate/3d-model", { ...rest, options }), "3D model");
|
|
802
|
+
})
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
server.tool(
|
|
806
|
+
"rig_3d_model",
|
|
807
|
+
"Add a skeleton to a humanoid 3D model so it can be animated (non-humanoids are refused before any " +
|
|
808
|
+
"charge). Run before animate_3d_model. 140 credits.",
|
|
809
|
+
{
|
|
810
|
+
id: z.number().int().describe("3D model asset ID"),
|
|
811
|
+
height_meters: z.number().min(0.2).max(10).default(1.7).describe("Character height in metres"),
|
|
812
|
+
},
|
|
813
|
+
writeTool("Rig 3D model"),
|
|
814
|
+
tool(async (args) => {
|
|
815
|
+
const data = await apiPost("/generate/model-rig", args);
|
|
816
|
+
return { content: [{ type: "text", text: `Rigged model ${args.id}: ${cdnUrl(data.rigged_glb || data.rigged_file)}\n${money(data)}` }] };
|
|
817
|
+
})
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
server.tool(
|
|
821
|
+
"animate_3d_model",
|
|
822
|
+
"Animate a rigged 3D model from a description of the motion. 30 credits.",
|
|
823
|
+
{
|
|
824
|
+
id: z.number().int().describe("Rigged 3D model asset ID"),
|
|
825
|
+
prompt: z.string().describe("The motion, e.g. 'walk cycle', 'idle breathing'"),
|
|
826
|
+
duration: z.number().min(1).max(10).default(3).describe("Seconds"),
|
|
827
|
+
},
|
|
828
|
+
writeTool("Animate 3D model"),
|
|
829
|
+
tool(async (args) => {
|
|
830
|
+
const data = await apiPost("/generate/model-animation", args);
|
|
831
|
+
const anim = data.animation || {};
|
|
832
|
+
return {
|
|
833
|
+
content: [{ type: "text", text: `Animation "${anim.name || args.prompt}" on model ${args.id}: ${cdnUrl(anim.file)}\n${money(data)}` }],
|
|
834
|
+
};
|
|
835
|
+
})
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
// The inline gallery, and the tools whose results it shows.
|
|
839
|
+
server.registerResource(
|
|
840
|
+
"gallery",
|
|
841
|
+
GALLERY_URI,
|
|
842
|
+
{ title: "Voidless results", description: "Shows the images, animations, video and audio a tool made.", mimeType: GALLERY_MIME },
|
|
843
|
+
async () => ({
|
|
844
|
+
contents: [{
|
|
845
|
+
uri: GALLERY_URI,
|
|
846
|
+
mimeType: GALLERY_MIME,
|
|
847
|
+
text: GALLERY_HTML,
|
|
848
|
+
_meta: {
|
|
849
|
+
ui: { csp: { resourceDomains: [CDN_URL] }, prefersBorder: false },
|
|
850
|
+
"openai/widgetCSP": { resource_domains: [CDN_URL], connect_domains: [] },
|
|
851
|
+
"openai/widgetDescription": "The images and animations Voidless just made.",
|
|
852
|
+
},
|
|
853
|
+
}],
|
|
854
|
+
})
|
|
855
|
+
);
|
|
856
|
+
const galleryMeta = { ui: { resourceUri: GALLERY_URI }, "ui/resourceUri": GALLERY_URI, "openai/outputTemplate": GALLERY_URI };
|
|
857
|
+
for (const name of GALLERY_TOOLS) {
|
|
858
|
+
const t = server._registeredTools?.[name];
|
|
859
|
+
if (t) t.update({ _meta: { ...(t._meta || {}), ...galleryMeta } });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return server;
|
|
863
|
+
}
|