keyframe-mcp 0.2.1 → 0.4.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 +84 -2
- package/package.json +6 -2
- package/runner.mjs +65 -3
- package/server.mjs +2 -2
- package/session.mjs +83 -13
- package/tools.mjs +68 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# keyframe-mcp
|
|
2
2
|
|
|
3
|
-
Create, inspect and export 2D skeletal animations in [Keyframe.it](https://www.keyframe.it.com/) from any MCP client.
|
|
3
|
+
Create, inspect and export 2D skeletal animations in [Keyframe.it](https://www.keyframe.it.com/), rig, animate, play-test and export pixel-art sprites in [Sprite Puppeteer](https://www.keyframe.it.com/sprite-puppeteer), and rig and animate 3D models in [MeshForge](https://www.keyframe.it.com/meshforge), from any MCP client.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -18,6 +18,12 @@ Other clients:
|
|
|
18
18
|
|
|
19
19
|
Version 0.2.0 adds persistent projects, named tools with validated schemas, complete clip creation, animation checks, editable image layers and file exports. Restart the MCP client after updating. For a local checkout, run `npm ci` in `mcp/`, then configure `node` with the absolute path to `mcp/server.mjs`.
|
|
20
20
|
|
|
21
|
+
Version 0.2.1 adds ownership metadata for the official MCP Registry, where the server is listed as `io.github.kendall8388/keyframe`.
|
|
22
|
+
|
|
23
|
+
Version 0.3.0 adds 18 `puppeteer_*` tools for Sprite Puppeteer: sample and your own characters, camera sets and directions, contact sheets, keyframed moves, and sheet, Godot, Unity, GameMaker, PNG-frame, Spine, GIF and project exports.
|
|
24
|
+
|
|
25
|
+
Version 0.4.0 adds 24 `meshforge_*` tools for MeshForge: rig a 3D model in the browser (template skeleton, joints read off a measuring grid, automatic skin weights), give held props their own rigid bone, animate from keyframe specs written in character space, judge motion from contact sheets and measurements, save to the library and export GLB.
|
|
26
|
+
|
|
21
27
|
## Workflow
|
|
22
28
|
|
|
23
29
|
1. `keyframe_open` resumes a dedicated Chrome profile and waits for saved projects to restore.
|
|
@@ -58,6 +64,82 @@ Read `keyframe_session` and pass its `projectId` and `revision` in `expected` to
|
|
|
58
64
|
|
|
59
65
|
Every named tool advertises its JSON Schema. Errors use MCP `isError:true` and `{ok:false,error}`. The generic call tool remains available for rigging, slicing, meshes, physics and other advanced methods.
|
|
60
66
|
|
|
67
|
+
## Sprite Puppeteer
|
|
68
|
+
|
|
69
|
+
[Sprite Puppeteer](https://www.keyframe.it.com/sprite-puppeteer) rigs and animates pixel-art sprites and lets you play-test them in an arena. It opens as a second page in the same connector profile. There are two kinds of character: **rigged** (one image with a skeleton, built-in moves plus moves you create) and **sprite-sheet** (every frame already drawn, in camera sets such as side, isometric and top-down, with up to eight directions).
|
|
70
|
+
|
|
71
|
+
1. `puppeteer_open` (optionally `{sample:"orc"}` or `{sample:"golem"}`), or `puppeteer_load_image {path:"/abs/path/hero.png"}` for your own art (PNG, GIF, WebP, JPEG, Aseprite or PSD; the skeleton auto-fits).
|
|
72
|
+
2. `puppeteer_state` lists moves, camera sets and directions (or views), and settings.
|
|
73
|
+
3. Judge motion with `puppeteer_contact_sheet {move:"walk"}`; pick angles with `puppeteer_set_view {set:"iso8", dir:"S"}`.
|
|
74
|
+
4. Rigged characters: read `puppeteer_move_guide`, then `puppeteer_create_move` with keys that list only the channels that change. Check it with a contact sheet and refine with `replace`.
|
|
75
|
+
5. `puppeteer_export {format:"godot", moves:"all", dirs:"8"}` (or sheet, unity, gm, frames, spine, gif, project) writes the file and returns a resource link and local path.
|
|
76
|
+
6. `puppeteer_open_result` shows the page in visible Chrome.
|
|
77
|
+
|
|
78
|
+
Example move for the sample golem (`puppeteer_open {sample:"golem"}` first):
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{"spec":{"name":"Overhead slam","dur":1.1,"loop":false,"keys":[{"t":0,"pose":{}},{"t":0.35,"ease":"out","pose":{"y":18,"torso":-12,"uarmR":150,"uarmL":150}},{"t":0.55,"ease":"snap","pose":{"y":6,"torso":30,"uarmR":-40,"uarmL":-40}},{"t":1.1,"pose":{}}],"events":[{"t":0.55,"type":"slam"}]}}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Tool | Purpose |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `puppeteer_open` | Open Sprite Puppeteer, optionally with a sample |
|
|
87
|
+
| `puppeteer_methods` / `puppeteer_call` | Every scripting method, and a generic call |
|
|
88
|
+
| `puppeteer_state` | Character, moves, camera or views, settings |
|
|
89
|
+
| `puppeteer_play_move` | Show a move on the stage |
|
|
90
|
+
| `puppeteer_set_view` | Camera set and direction (sheets), view (rigged), facing |
|
|
91
|
+
| `puppeteer_settings` | Background, fps, pixel scale, speed, effects, export options |
|
|
92
|
+
| `puppeteer_move_guide` / `puppeteer_create_move` | Pose channels and conventions; add or replace a keyframed move |
|
|
93
|
+
| `puppeteer_contact_sheet` / `puppeteer_render_frame` | Frames as images |
|
|
94
|
+
| `puppeteer_load_image` | Your sprite from disk as a new rigged character |
|
|
95
|
+
| `puppeteer_export` | Sheet, Godot, Unity, GameMaker, PNG frames, Spine, GIF or project file |
|
|
96
|
+
| `puppeteer_list_projects` / `puppeteer_open_project` / `puppeteer_save` | Projects in the connector profile |
|
|
97
|
+
| `puppeteer_screenshot` / `puppeteer_open_result` | The page, and handoff to visible Chrome |
|
|
98
|
+
|
|
99
|
+
The page's API is `window.spritePuppeteer`; any browser automation can call it directly. See [llms.txt](https://www.keyframe.it.com/llms.txt).
|
|
100
|
+
|
|
101
|
+
## MeshForge
|
|
102
|
+
|
|
103
|
+
[MeshForge](https://www.keyframe.it.com/meshforge) turns images into 3D models (on the user's own fal.ai key) and rigs, animates and renders them in the browser. It opens as a third page in the same connector profile; a new profile starts with an example orc (rigged, 18 animations). Positions and directions are in **character space**: +x the character's right, +y up from the floor, +z the way it faces, in metres.
|
|
104
|
+
|
|
105
|
+
Animate a rigged model:
|
|
106
|
+
|
|
107
|
+
1. `meshforge_open` (optionally `{model:"Orc"}`), `meshforge_models`, `meshforge_open_model`, or `meshforge_import_model {path:"/abs/path/hero.glb"}`.
|
|
108
|
+
2. Read `meshforge_animation_guide` once: it explains the spec and lists the skeleton with each bone's rest aim.
|
|
109
|
+
3. `meshforge_build_animation` with keys whose bones take `{aim:[x,y,z]}` (point the bone that way), `{rot:{yaw,pitch,roll}}` (degrees from rest) or `{rest:true}`. Feet stay on the floor automatically; `root.move[1]` is jump height.
|
|
110
|
+
4. Judge it with `meshforge_contact_sheet` (front and side) and the measurements in the result (`feetToFloor`, `footSlide`, `loopGap`); refine with `replace:true`.
|
|
111
|
+
5. `meshforge_save` keeps it in the library; `meshforge_export` writes a .glb.
|
|
112
|
+
|
|
113
|
+
Rig an unrigged model (free, no API key):
|
|
114
|
+
|
|
115
|
+
1. `meshforge_start_rig {template:"humanoid"}` (or quadruped, chain) fits a skeleton to the mesh.
|
|
116
|
+
2. `meshforge_render_rig` shows the joints over a measuring grid from the front and side. Read the right coordinates off the grid and fix joints with `meshforge_set_joints {joints:{forearmR:[0.55,0.74,0.05]}}` (mirrored by default). Joints belong inside the body.
|
|
117
|
+
3. Props: `meshforge_add_prop {name:"axe", parent:"handR", select:{capsule:{a:[…],b:[…],radius:0.08}}}`. Preview a selection first with `meshforge_render_rig {select:…}` (orange); added props show purple.
|
|
118
|
+
4. `meshforge_bind_rig`, then test with an animation and save.
|
|
119
|
+
|
|
120
|
+
Example (the example orc, `meshforge_open {model:"Orc"}` first):
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{"spec":{"name":"chop","duration":1.2,"keys":[{"t":0,"bones":{"upper_armR":{"rest":true},"forearmR":{"rest":true},"spine1":{"rest":true}}},{"t":0.45,"ease":"easeOut","bones":{"upper_armR":{"aim":[0.3,1,-0.4]},"forearmR":{"aim":[0,0.3,-1]},"spine1":{"rot":{"pitch":-12,"yaw":-15}}}},{"t":0.65,"ease":"easeIn","bones":{"upper_armR":{"aim":[0.2,-0.3,1]},"forearmR":{"aim":[0,-0.8,1]},"spine1":{"rot":{"pitch":20,"yaw":10}}}},{"t":1.2,"bones":{"upper_armR":{"rest":true},"forearmR":{"rest":true},"spine1":{"rest":true}}}]}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Tool | Purpose |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `meshforge_open` | Open MeshForge, optionally a library model |
|
|
129
|
+
| `meshforge_methods` / `meshforge_call` | Every scripting method, and a generic call |
|
|
130
|
+
| `meshforge_state` / `meshforge_models` / `meshforge_open_model` | The open model, the library, opening one |
|
|
131
|
+
| `meshforge_import_model` | A .glb from disk into the library |
|
|
132
|
+
| `meshforge_generate` / `meshforge_wait` | Image → 3D on the user's fal.ai key (paid: needs `confirmCost:true`) |
|
|
133
|
+
| `meshforge_skeleton` / `meshforge_animation_guide` | Bones in character space; how to write a spec |
|
|
134
|
+
| `meshforge_build_animation` | Keyframe spec → animation, with measurements |
|
|
135
|
+
| `meshforge_contact_sheet` / `meshforge_render_frame` / `meshforge_inspect` | Frames as images; numbers |
|
|
136
|
+
| `meshforge_start_rig` / `meshforge_render_rig` / `meshforge_set_joints` / `meshforge_bind_rig` | Rigging in the browser |
|
|
137
|
+
| `meshforge_add_prop` | A rigid bone for a held weapon or shield (draft or bound rig) |
|
|
138
|
+
| `meshforge_save` / `meshforge_export` | Library entry; .glb file |
|
|
139
|
+
| `meshforge_screenshot` / `meshforge_open_result` | The page, and handoff to visible Chrome |
|
|
140
|
+
|
|
141
|
+
Unlike the other pages, MeshForge doesn't save after every call (a save writes a whole model): results say `unsaved` until `meshforge_save`, and the connector saves on handoff and shutdown. The page's API is `window.meshforge`.
|
|
142
|
+
|
|
61
143
|
## Storage and handoff
|
|
62
144
|
|
|
63
145
|
The connector uses **its own Chrome profile**, not existing personal Chrome tabs. Projects belong to that profile and site origin. Export project JSON and import it in your usual browser to transfer work. Existing projects from version 0.1.0's temporary browser profile cannot be recovered unless previously exported.
|
|
@@ -75,4 +157,4 @@ Close or Apply the human image editor before agent layer edits. Layers retain te
|
|
|
75
157
|
|
|
76
158
|
`npm test` runs schema, protocol, persistence, serialization, error and file-resource tests with a fake browser. `../e2e/agent-workflow.html` exercises the real editor API, canvas renders, layer compositing, exports and save/reload through a Vite dev server.
|
|
77
159
|
|
|
78
|
-
MIT — see LICENSE. This license covers the connector; the editor is a separate hosted application.
|
|
160
|
+
MIT — see LICENSE. This license covers the connector; the editor is a separate hosted application.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyframe-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.kendall8388/keyframe",
|
|
5
5
|
"repository": { "type": "git", "url": "https://github.com/kendall8388/Keyframe-it.git", "directory": "mcp" },
|
|
6
|
-
"description": "MCP server for Keyframe.it — let Claude (or any MCP client) rig, animate and export 2D skeletal animations
|
|
6
|
+
"description": "MCP server for Keyframe.it — let Claude (or any MCP client) rig, animate and export 2D skeletal animations in the free Keyframe.it editor, rig, animate, play-test and export pixel-art sprites in Sprite Puppeteer, and rig and animate 3D models in MeshForge, by driving them in your browser.",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
9
|
"keyframe-mcp": "server.mjs"
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"spine",
|
|
39
39
|
"pixi-spine",
|
|
40
40
|
"game-development",
|
|
41
|
+
"pixel-art",
|
|
42
|
+
"sprite-sheet",
|
|
43
|
+
"godot",
|
|
44
|
+
"unity",
|
|
41
45
|
"gamedev",
|
|
42
46
|
"sprite",
|
|
43
47
|
"slot-symbols",
|
package/runner.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Ajv from 'ajv'
|
|
2
|
-
import { mkdir, writeFile, readFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, writeFile, readFile, stat } from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
-
import { join, resolve } from 'node:path'
|
|
4
|
+
import { join, resolve, basename, extname } from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
7
|
import { definitions } from './tools.mjs'
|
|
@@ -15,6 +15,26 @@ export function toContent(result) {
|
|
|
15
15
|
}
|
|
16
16
|
return { content: [{ type: 'text', text: JSON.stringify(result) ?? 'null' }], ...(result && typeof result === 'object' && !Array.isArray(result) ? { structuredContent: result } : {}), ...(result?.ok === false ? { isError: true } : {}) }
|
|
17
17
|
}
|
|
18
|
+
/* Sprite Puppeteer loads art from the user's disk: images, Aseprite and PSD only, as a data URL the page can read */
|
|
19
|
+
const ART_TYPES = { '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.aseprite': 'application/octet-stream', '.ase': 'application/octet-stream', '.psd': 'image/vnd.adobe.photoshop' }
|
|
20
|
+
const ART_MAX = 40 * 1024 * 1024
|
|
21
|
+
export async function artDataUrl(path) {
|
|
22
|
+
const type = ART_TYPES[extname(path).toLowerCase()]
|
|
23
|
+
if (!type) throw new Error(`Sprite Puppeteer loads ${Object.keys(ART_TYPES).join(', ')} files`)
|
|
24
|
+
const info = await stat(path)
|
|
25
|
+
if (!info.isFile()) throw new Error(`${path} is not a file`)
|
|
26
|
+
if (info.size > ART_MAX) throw new Error('That file is over 40 MB')
|
|
27
|
+
return `data:${type};base64,${(await readFile(path)).toString('base64')}`
|
|
28
|
+
}
|
|
29
|
+
/* MeshForge imports .glb models from the user's disk */
|
|
30
|
+
const GLB_MAX = 200 * 1024 * 1024
|
|
31
|
+
export async function glbDataUrl(path) {
|
|
32
|
+
if (extname(path).toLowerCase() !== '.glb') throw new Error('MeshForge imports .glb files')
|
|
33
|
+
const info = await stat(path)
|
|
34
|
+
if (!info.isFile()) throw new Error(`${path} is not a file`)
|
|
35
|
+
if (info.size > GLB_MAX) throw new Error('That file is over 200 MB')
|
|
36
|
+
return `data:model/gltf-binary;base64,${(await readFile(path)).toString('base64')}`
|
|
37
|
+
}
|
|
18
38
|
export class ToolRunner {
|
|
19
39
|
constructor(session, outputDir = process.env.KEYFRAME_EXPORT_DIR || join(homedir(), '.keyframe-mcp', 'exports')) {
|
|
20
40
|
this.session = session
|
|
@@ -32,6 +52,8 @@ export class ToolRunner {
|
|
|
32
52
|
const definition = registered.get(name)
|
|
33
53
|
if (!definition) throw new Error(`Unknown tool: ${name}`)
|
|
34
54
|
if (!definition.validate(args)) throw new Error(`Invalid arguments: ${ajv.errorsText(definition.validate.errors)}`)
|
|
55
|
+
if (definition.target === 'puppeteer') return this.dispatchPuppeteer(name, definition, args)
|
|
56
|
+
if (definition.target === 'meshforge') return this.dispatchMeshforge(name, definition, args)
|
|
35
57
|
const page = await this.session.open(name === 'keyframe_open' ? args.url : undefined)
|
|
36
58
|
if (name === 'keyframe_open_result') return toContent(await this.session.handoff())
|
|
37
59
|
if (name === 'keyframe_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
|
|
@@ -46,6 +68,46 @@ export class ToolRunner {
|
|
|
46
68
|
if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
|
|
47
69
|
return toContent(result)
|
|
48
70
|
}
|
|
71
|
+
/* Sprite Puppeteer: a second page in the same profile, driven through window.spritePuppeteer */
|
|
72
|
+
async dispatchPuppeteer(name, definition, args) {
|
|
73
|
+
const page = await this.session.openPuppeteer()
|
|
74
|
+
if (name === 'puppeteer_open_result') return toContent(await this.session.puppeteerHandoff())
|
|
75
|
+
if (name === 'puppeteer_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
|
|
76
|
+
let method = definition.method, callArgs
|
|
77
|
+
if (name === 'puppeteer_call') { method = args.method; callArgs = args.args ?? [] }
|
|
78
|
+
else if (name === 'puppeteer_open') { method = args.sample ? 'loadSample' : 'getState'; callArgs = args.sample ? [args.sample] : [] }
|
|
79
|
+
else if (name === 'puppeteer_load_image') callArgs = [await artDataUrl(resolve(args.path)), { name: args.name || basename(args.path) }]
|
|
80
|
+
else callArgs = definition.args(args)
|
|
81
|
+
const result = await this.session.invokePuppeteer(method, callArgs)
|
|
82
|
+
if (result?.ok !== false) {
|
|
83
|
+
try { await this.session.savePuppeteer() }
|
|
84
|
+
catch (e) { return toContent({ ok: false, error: `The command changed the character, but saving failed: ${e.message}. Retry puppeteer_save, or export a project file.`, applied: true }) }
|
|
85
|
+
}
|
|
86
|
+
if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
|
|
87
|
+
return toContent(name === 'puppeteer_open' ? { ...result, profile: this.session.profile, visible: !this.session.headless, handoffTool: 'puppeteer_open_result' } : result)
|
|
88
|
+
}
|
|
89
|
+
/* MeshForge: a third page in the same profile, driven through window.meshforge. Its saves write a
|
|
90
|
+
whole model into the library, so they happen on meshforge_save, on handoff and on close — not
|
|
91
|
+
after every call (results say when there are unsaved changes). */
|
|
92
|
+
async dispatchMeshforge(name, definition, args) {
|
|
93
|
+
const page = await this.session.openMeshforge()
|
|
94
|
+
if (name === 'meshforge_open_result') return toContent(await this.session.meshforgeHandoff())
|
|
95
|
+
if (name === 'meshforge_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
|
|
96
|
+
let method = definition.method, callArgs
|
|
97
|
+
if (name === 'meshforge_call') { method = args.method; callArgs = args.args ?? [] }
|
|
98
|
+
else if (name === 'meshforge_open') { method = args.model ? 'openModel' : 'getState'; callArgs = args.model ? [args.model] : [] }
|
|
99
|
+
else if (name === 'meshforge_import_model') { method = 'importModel'; callArgs = [await glbDataUrl(resolve(args.path)), { name: args.name || basename(args.path) }] }
|
|
100
|
+
else if (name === 'meshforge_generate') { method = 'generateModel'; callArgs = [{ image: await artDataUrl(resolve(args.image)), name: basename(args.image), model: args.model, options: args.options, confirmCost: args.confirmCost }] }
|
|
101
|
+
else callArgs = definition.args(args)
|
|
102
|
+
const result = await this.session.invokeMeshforge(method, callArgs)
|
|
103
|
+
if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
|
|
104
|
+
let out = result
|
|
105
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && result.ok !== false && !result.dataUrl) {
|
|
106
|
+
const info = await this.session.invokeMeshforge('sessionInfo')
|
|
107
|
+
if (info?.dirty) out = { ...result, unsaved: 'Changes are in the viewer only: meshforge_save keeps them in the library.' }
|
|
108
|
+
}
|
|
109
|
+
return toContent(name === 'meshforge_open' ? { ...out, profile: this.session.profile, visible: !this.session.headless, handoffTool: 'meshforge_open_result' } : out)
|
|
110
|
+
}
|
|
49
111
|
async writeArtifact(result) {
|
|
50
112
|
const artifact = result.artifact
|
|
51
113
|
const match = artifact.dataUrl?.match(/^data:([^;,]*)(?:;[^,]*)?;base64,(.*)$/s)
|
|
@@ -57,7 +119,7 @@ export class ToolRunner {
|
|
|
57
119
|
await writeFile(path, bytes, { flag: 'wx' })
|
|
58
120
|
const resource = { uri: pathToFileURL(path).href, name: safeName, mimeType: artifact.mimeType || match[1], size: bytes.length }
|
|
59
121
|
this.artifacts.set(resource.uri, { resource, path })
|
|
60
|
-
const metadata = { ok: true, projectId: result.projectId, path, ...resource }
|
|
122
|
+
const metadata = { ok: true, projectId: result.projectId, path, ...resource, ...(result.detail ? { detail: result.detail } : {}) }
|
|
61
123
|
return { content: [{ type: 'text', text: JSON.stringify(metadata) }, { type: 'resource_link', ...resource }], structuredContent: metadata }
|
|
62
124
|
}
|
|
63
125
|
resources() { return [...this.artifacts.values()].map(a => a.resource) }
|
package/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { advertisedTools } from './tools.mjs'
|
|
|
10
10
|
|
|
11
11
|
export function createServer(session = new EditorSession(chromium)) {
|
|
12
12
|
const runner = new ToolRunner(session)
|
|
13
|
-
const server = new Server({ name: 'keyframe-mcp', version: '0.
|
|
13
|
+
const server = new Server({ name: 'keyframe-mcp', version: '0.4.0' }, { capabilities: { tools: {}, resources: {} } })
|
|
14
14
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertisedTools }))
|
|
15
15
|
server.setRequestHandler(CallToolRequestSchema, req => runner.call(req.params.name, req.params.arguments ?? {}))
|
|
16
16
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: runner.resources() }))
|
|
@@ -32,4 +32,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
32
32
|
process.on('SIGTERM', shutdown)
|
|
33
33
|
process.stdin.on('end', shutdown)
|
|
34
34
|
await server.connect(new StdioServerTransport())
|
|
35
|
-
}
|
|
35
|
+
}
|
package/session.mjs
CHANGED
|
@@ -5,6 +5,16 @@ export function editorUrl(value) {
|
|
|
5
5
|
if (url.username || url.password || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw new Error('Use HTTPS, or HTTP on localhost, for the editor URL')
|
|
6
6
|
return url.href
|
|
7
7
|
}
|
|
8
|
+
/* Call a method on a page's scripting API (globalThis.keyframe or globalThis.spritePuppeteer). */
|
|
9
|
+
function callApi(page, global, method, args) {
|
|
10
|
+
return page.evaluate(async ({ global, method, args }) => {
|
|
11
|
+
const api = globalThis[global]
|
|
12
|
+
if (!api || !Object.prototype.hasOwnProperty.call(api, method) || typeof api[method] !== 'function') return { ok: false, error: `Unknown API method ${method}; check the methods tool and update the page if needed` }
|
|
13
|
+
try { return (await api[method](...args)) ?? { ok: true } }
|
|
14
|
+
catch (e) { return { ok: false, error: String(e?.message ?? e) } }
|
|
15
|
+
}, { global, method, args })
|
|
16
|
+
}
|
|
17
|
+
/* One persistent Chrome profile with up to three pages: the Keyframe.it editor, Sprite Puppeteer and MeshForge. */
|
|
8
18
|
export class EditorSession {
|
|
9
19
|
constructor(chromium, env = process.env) {
|
|
10
20
|
this.chromium = chromium
|
|
@@ -13,14 +23,20 @@ export class EditorSession {
|
|
|
13
23
|
this.headless = env.KEYFRAME_HEADLESS !== '0'
|
|
14
24
|
this.context = null
|
|
15
25
|
this.page = null
|
|
26
|
+
this.pp = null
|
|
27
|
+
this.mf = null
|
|
16
28
|
}
|
|
17
|
-
async
|
|
18
|
-
const target = url ? editorUrl(url) : this.url
|
|
19
|
-
if (this.page && !this.page.isClosed() && this.page.url() !== target) await this.save()
|
|
29
|
+
async ensureContext() {
|
|
20
30
|
if (!this.context) {
|
|
21
31
|
this.context = await this.chromium.launchPersistentContext(this.profile, { channel: 'chrome', headless: this.headless, viewport: { width: 1440, height: 900 } })
|
|
22
|
-
this.context.on('close', () => { this.context = null; this.page = null })
|
|
32
|
+
this.context.on('close', () => { this.context = null; this.page = null; this.pp = null; this.mf = null })
|
|
23
33
|
}
|
|
34
|
+
return this.context
|
|
35
|
+
}
|
|
36
|
+
async open(url) {
|
|
37
|
+
const target = url ? editorUrl(url) : this.url
|
|
38
|
+
if (this.page && !this.page.isClosed() && this.page.url() !== target) await this.save()
|
|
39
|
+
await this.ensureContext()
|
|
24
40
|
if (!this.page || this.page.isClosed()) this.page = this.context.pages().find(p => p.url() === target) ?? await this.context.newPage()
|
|
25
41
|
if (this.page.url() !== target) await this.page.goto(target, { waitUntil: 'domcontentloaded' })
|
|
26
42
|
this.url = target
|
|
@@ -30,14 +46,7 @@ export class EditorSession {
|
|
|
30
46
|
this.url = this.page.url()
|
|
31
47
|
return this.page
|
|
32
48
|
}
|
|
33
|
-
|
|
34
|
-
return this.page.evaluate(async ({ method, args }) => {
|
|
35
|
-
const api = globalThis.keyframe
|
|
36
|
-
if (!api || !Object.prototype.hasOwnProperty.call(api, method) || typeof api[method] !== 'function') return { ok: false, error: `Unknown API method ${method}; check keyframe_methods and update the editor if needed` }
|
|
37
|
-
try { return (await api[method](...args)) ?? { ok: true } }
|
|
38
|
-
catch (e) { return { ok: false, error: String(e?.message ?? e) } }
|
|
39
|
-
}, { method, args })
|
|
40
|
-
}
|
|
49
|
+
invoke(method, args = []) { return callApi(this.page, 'keyframe', method, args) }
|
|
41
50
|
async save() {
|
|
42
51
|
if (!this.page || this.page.isClosed()) return
|
|
43
52
|
const saved = await this.invoke('saveProject')
|
|
@@ -51,5 +60,66 @@ export class EditorSession {
|
|
|
51
60
|
await this.page.bringToFront()
|
|
52
61
|
return { ok: true, ...saved, url: this.url, visible: true, profile: this.profile, note: 'Continue editing in the opened Chrome window. This dedicated profile is separate from your usual browser. Export project JSON to transfer it.' }
|
|
53
62
|
}
|
|
54
|
-
|
|
63
|
+
|
|
64
|
+
/* ---------- Sprite Puppeteer (same site, /sprite-puppeteer) ---------- */
|
|
65
|
+
puppeteerUrl() { return new URL('/sprite-puppeteer', this.url).href }
|
|
66
|
+
async openPuppeteer() {
|
|
67
|
+
await this.ensureContext()
|
|
68
|
+
const target = this.puppeteerUrl()
|
|
69
|
+
if (!this.pp || this.pp.isClosed()) this.pp = this.context.pages().find(p => p.url().startsWith(target)) ?? await this.context.newPage()
|
|
70
|
+
if (!this.pp.url().startsWith(target)) await this.pp.goto(target, { waitUntil: 'domcontentloaded' })
|
|
71
|
+
await this.pp.waitForFunction(() => !!globalThis.spritePuppeteer, undefined, { timeout: 60000 })
|
|
72
|
+
const status = await this.invokePuppeteer('ready')
|
|
73
|
+
if (status?.ok === false) throw new Error(status.error)
|
|
74
|
+
return this.pp
|
|
75
|
+
}
|
|
76
|
+
invokePuppeteer(method, args = []) { return callApi(this.pp, 'spritePuppeteer', method, args) }
|
|
77
|
+
/* save only when the character changed: a sample nobody touched stays a sample, as it does for people */
|
|
78
|
+
async savePuppeteer() {
|
|
79
|
+
if (!this.pp || this.pp.isClosed()) return
|
|
80
|
+
const info = await this.invokePuppeteer('sessionInfo')
|
|
81
|
+
if (!info?.dirty) return info
|
|
82
|
+
const saved = await this.invokePuppeteer('saveProject')
|
|
83
|
+
if (saved?.ok === false) throw new Error(saved.error)
|
|
84
|
+
return saved
|
|
85
|
+
}
|
|
86
|
+
async puppeteerHandoff() {
|
|
87
|
+
await this.openPuppeteer()
|
|
88
|
+
const saved = await this.savePuppeteer()
|
|
89
|
+
if (this.headless) { await this.context.close(); this.headless = false; await this.openPuppeteer() }
|
|
90
|
+
await this.pp.bringToFront()
|
|
91
|
+
return { ok: true, ...saved, url: this.pp.url(), visible: true, profile: this.profile, note: 'Sprite Puppeteer is open in the connector\'s Chrome window. Its projects live in this dedicated profile; use puppeteer_export {format:"project"} to move a character to your usual browser.' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ---------- MeshForge (same site, /meshforge/) ---------- */
|
|
95
|
+
meshforgeUrl() { return new URL('/meshforge/', this.url).href }
|
|
96
|
+
async openMeshforge() {
|
|
97
|
+
await this.ensureContext()
|
|
98
|
+
const target = this.meshforgeUrl(), base = target.replace(/\/$/, '')
|
|
99
|
+
if (!this.mf || this.mf.isClosed()) this.mf = this.context.pages().find(p => p.url().startsWith(base)) ?? await this.context.newPage()
|
|
100
|
+
if (!this.mf.url().startsWith(base)) await this.mf.goto(target, { waitUntil: 'domcontentloaded' })
|
|
101
|
+
await this.mf.waitForFunction(() => !!globalThis.meshforge, undefined, { timeout: 60000 })
|
|
102
|
+
const status = await this.invokeMeshforge('ready')
|
|
103
|
+
if (status?.ok === false) throw new Error(status.error)
|
|
104
|
+
return this.mf
|
|
105
|
+
}
|
|
106
|
+
invokeMeshforge(method, args = []) { return callApi(this.mf, 'meshforge', method, args) }
|
|
107
|
+
/* MeshForge saves a whole model (a GLB) into its library: only when something changed */
|
|
108
|
+
async saveMeshforge() {
|
|
109
|
+
if (!this.mf || this.mf.isClosed()) return
|
|
110
|
+
const info = await this.invokeMeshforge('sessionInfo')
|
|
111
|
+
if (!info?.dirty) return info
|
|
112
|
+
const saved = await this.invokeMeshforge('saveProject')
|
|
113
|
+
if (saved?.ok === false) throw new Error(saved.error)
|
|
114
|
+
return saved
|
|
115
|
+
}
|
|
116
|
+
async meshforgeHandoff() {
|
|
117
|
+
await this.openMeshforge()
|
|
118
|
+
const saved = await this.saveMeshforge()
|
|
119
|
+
const model = saved?.model?.id || (await this.invokeMeshforge('sessionInfo'))?.model
|
|
120
|
+
if (this.headless) { await this.context.close(); this.headless = false; await this.openMeshforge(); if (model) await this.invokeMeshforge('openModel', [model]) }
|
|
121
|
+
await this.mf.bringToFront()
|
|
122
|
+
return { ok: true, ...saved, url: this.mf.url(), visible: true, profile: this.profile, note: 'MeshForge is open in the connector\'s Chrome window. Its library lives in this dedicated profile; use meshforge_export to take a model to your usual browser (+ Import GLB).' }
|
|
123
|
+
}
|
|
124
|
+
async close() { try { await this.save(); await this.savePuppeteer(); await this.saveMeshforge() } finally { await this.context?.close() } }
|
|
55
125
|
}
|
package/tools.mjs
CHANGED
|
@@ -42,4 +42,71 @@ export const definitions = [
|
|
|
42
42
|
tool('keyframe_edit_image_layers', 'Atomically add text or embedded image layers, edit properties/text, reorder or delete layers. Updates composite and editable layers. Close the human image editor first.', object({ asset: str, operations: { type: 'array', minItems: 1, maxItems: 100, items: layerChange }, expected }, ['asset', 'operations']), 'editImageLayers', a => [a.asset, a.operations, a.expected]),
|
|
43
43
|
tool('keyframe_open_result', 'Save the project and show it in visible Chrome with the connector profile. Export project JSON to transfer to your usual browser.'),
|
|
44
44
|
]
|
|
45
|
-
|
|
45
|
+
/* ---------- Sprite Puppeteer (www.keyframe.it.com/sprite-puppeteer): pixel-art sprites, rigged or sprite-sheet ---------- */
|
|
46
|
+
const pp = (name, description, inputSchema = object(), method, args = () => []) => ({ ...tool(name, description, inputSchema, method, args), target: 'puppeteer' })
|
|
47
|
+
const dir8 = { enum: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] }
|
|
48
|
+
const ppKey = object({ t: number(0, 6), ease: { enum: ['io', 'out', 'in', 'lin', 'snap', 'back', 'hold'] }, pose: { type: 'object', additionalProperties: { type: 'number' } } }, ['t'])
|
|
49
|
+
const ppEvent = object({ t: number(0, 6), type: { enum: ['impact', 'slam', 'cast', 'step', 'launch', 'land', 'cheer'] }, at: str }, ['t', 'type'])
|
|
50
|
+
const ppSettings = { ...object({
|
|
51
|
+
bg: { enum: ['crypt', 'checker', 'plain', 'green'] }, fps: integer(1, 60), scale: integer(1, 8), speed: number(0.25, 2), intensity: number(0.4, 1.6), glow: number(0, 1.5),
|
|
52
|
+
hitStop: number(0, 0.16), overshoot: number(0, 1), follow: number(0, 1), fx: bool, smear: bool, stepped: bool, bones: bool, exportFx: bool, dummy: bool,
|
|
53
|
+
format: { enum: ['sheet', 'godot', 'unity', 'gm', 'frames', 'spine'] }, which: { enum: ['one', 'all'] }, dirs: { enum: ['view', '4', '8'] },
|
|
54
|
+
outline: { enum: ['off', 'repair', 'add'] }, rotsprite: { enum: ['auto', 'on', 'off'] }, facing: { enum: [1, -1] },
|
|
55
|
+
}), minProperties: 1 }
|
|
56
|
+
definitions.push(
|
|
57
|
+
pp('puppeteer_open', 'Open Sprite Puppeteer (pixel-art sprite rigging, animation and play-testing) in the connector profile; optionally load a sample: "orc" or "knight" (sprite sheets: side, isometric, top-down) or "golem" (rigged). Returns the character state. Call first.', object({ sample: { enum: ['orc', 'knight', 'golem'] } })),
|
|
58
|
+
pp('puppeteer_methods', 'List every Sprite Puppeteer scripting method with arguments, plus recipes. Reach methods without a named tool through puppeteer_call.', object(), 'help'),
|
|
59
|
+
pp('puppeteer_call', 'Call any window.spritePuppeteer method with positional arguments. Read puppeteer_methods first. Image results appear inline; exports become files.', object({ method: str, args: { type: 'array' } }, ['method'])),
|
|
60
|
+
pp('puppeteer_state', 'Current character: kind (rigged or sprite-sheet), moves, camera set and direction or views, settings, project.', object(), 'getState'),
|
|
61
|
+
pp('puppeteer_play_move', 'Show a move on the stage, optionally from a time or paused.', object({ move: str, time: number(0, 60), play: bool }, ['move']), 'playMove', a => [a.move, { time: a.time, play: a.play }]),
|
|
62
|
+
pp('puppeteer_set_view', 'Sprite sheets: camera set (side, iso8, topdown8, topdown4…) and direction (S = facing down). Rigged: drawn view (side, front, back, fq, bq). Both: facing 1 (right) or -1 (left).', { ...object({ set: str, dir: dir8, view: str, facing: { enum: [1, -1] } }), minProperties: 1 }, 'setView', a => [a]),
|
|
63
|
+
pp('puppeteer_settings', 'Change settings through the panel: background (checker is transparent in exports), fps, pixel scale, speed, exaggeration, effects, export format, one/all moves, 4/8 directions.', ppSettings, 'setSettings', a => [a]),
|
|
64
|
+
pp('puppeteer_move_guide', 'Rigged characters: pose channels with units, ranges and rest values, animation conventions, eases, events and the JSON shape for puppeteer_create_move. Read before creating a move.', object(), 'moveGuide'),
|
|
65
|
+
pp('puppeteer_create_move', 'Rigged characters: add a keyframed move. keys list only channels that differ from rest; a loop flows back to its first key. replace: an existing custom move id to overwrite (keeps its id and hotkey).', object({
|
|
66
|
+
spec: object({ name: str, dur: number(0.2, 6), loop: bool, keys: { type: 'array', minItems: 1, maxItems: 64, items: ppKey }, events: { type: 'array', maxItems: 32, items: ppEvent } }, ['dur', 'keys']), replace: str }, ['spec']), 'createMove', a => [a.spec, { replace: a.replace }]),
|
|
67
|
+
pp('puppeteer_contact_sheet', 'A labelled grid of a move\'s frames (cells about 220 px): the way to judge a move. Defaults to the current move.', object({ move: str, samples: integer(1, 36), scale: number(0.1, 6), columns: integer(1, 12) }), 'renderContactSheet', a => [a]),
|
|
68
|
+
pp('puppeteer_render_frame', 'One frame of a move as a PNG, transparent unless background:true.', object({ move: str, frame: integer(0, 9999), time: number(0, 60), scale: integer(1, 8), background: bool }), 'renderFrame', a => [a]),
|
|
69
|
+
pp('puppeteer_load_image', 'Load a sprite from the local disk as a new rigged character (PNG, GIF, WebP, JPEG, .aseprite/.ase or .psd; layers keep their names). The skeleton auto-fits; check with puppeteer_contact_sheet.', object({ path: str, name: str }, ['path']), 'loadImage'),
|
|
70
|
+
pp('puppeteer_export', 'Write an export to disk: sheet (PNG + Aseprite-style JSON), godot, unity, gm (GameMaker), frames (PNG zip), spine (rigged only), gif (one move) or project (.puppet.json). moves: current or all; dirs: view, 4 or 8.', object({ format: { enum: ['sheet', 'godot', 'unity', 'gm', 'frames', 'spine', 'gif', 'project'] }, move: str, moves: { enum: ['current', 'all'] }, dirs: { enum: ['view', '4', '8'] } }, ['format']), 'exportFile', a => [a]),
|
|
71
|
+
pp('puppeteer_list_projects', 'List Sprite Puppeteer projects saved in this connector profile.', object(), 'listProjects'),
|
|
72
|
+
pp('puppeteer_open_project', 'Open a saved Sprite Puppeteer project by ID.', object({ projectId: str }, ['projectId']), 'openProject', a => [a.projectId]),
|
|
73
|
+
pp('puppeteer_save', 'Save the current character as a project, optionally naming it.', object({ name: str }), 'saveProject', a => [{ name: a.name }]),
|
|
74
|
+
pp('puppeteer_screenshot', 'Show the Sprite Puppeteer page. Prefer puppeteer_contact_sheet to judge motion.'),
|
|
75
|
+
pp('puppeteer_open_result', 'Save and show Sprite Puppeteer in visible Chrome with the connector profile, so the user can keep working.'),
|
|
76
|
+
)
|
|
77
|
+
/* ---------- MeshForge (www.keyframe.it.com/meshforge): 3D models — rig, animate, props, sprites ---------- */
|
|
78
|
+
const mf = (name, description, inputSchema = object(), method, args = () => []) => ({ ...tool(name, description, inputSchema, method, args), target: 'meshforge' })
|
|
79
|
+
const vec3 = { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 }
|
|
80
|
+
const views = { type: 'array', minItems: 1, maxItems: 7, items: { enum: ['front', 'threeQuarter', 'side', 'right', 'left', 'back', 'top'] } }
|
|
81
|
+
const rot = object({ yaw: { type: 'number' }, pitch: { type: 'number' }, roll: { type: 'number' } })
|
|
82
|
+
const boneTarget = { anyOf: [object({ aim: vec3, twist: { type: 'number' } }, ['aim']), object({ rot }, ['rot']), object({ rest: { const: true } }, ['rest'])] }
|
|
83
|
+
const mfKey = object({ t: number(0, 60), ease: { enum: ['linear', 'easeIn', 'easeOut', 'easeInOut', 'step'] }, root: object({ move: vec3, turn: rot }), bones: { type: 'object', additionalProperties: boneTarget } }, ['t'])
|
|
84
|
+
const shape = { type: 'object', description: 'Character space: {box:{min,max}} | {sphere:{center,radius}} | {capsule:{a,b,radius}} | {piece:[x,y,z]} | {any:[…]}, optional minus' }
|
|
85
|
+
definitions.push(
|
|
86
|
+
mf('meshforge_open', 'Open MeshForge (3D models: rig in the browser, animate from keyframe specs, prop bones, sprite sheets) in the connector profile, optionally opening a library model by id or name. A new profile starts with the example orc (rigged, 18 clips). Call first.', object({ model: str })),
|
|
87
|
+
mf('meshforge_methods', 'List every MeshForge scripting method (window.meshforge) with arguments, plus recipes. Reach methods without a named tool through meshforge_call.', object(), 'help'),
|
|
88
|
+
mf('meshforge_call', 'Call any window.meshforge method with positional arguments. Read meshforge_methods first. Images appear inline; exports become files.', object({ method: str, args: { type: 'array' } }, ['method'])),
|
|
89
|
+
mf('meshforge_state', 'The open model (rigged? bone count, height), its animations, what is playing, any rig draft, unsaved changes.', object(), 'getState'),
|
|
90
|
+
mf('meshforge_models', 'The MeshForge library in this connector profile: id, name, label (how it was made), status.', object(), 'listModels'),
|
|
91
|
+
mf('meshforge_open_model', 'Open a library model in the viewer by id or name.', object({ model: str }, ['model']), 'openModel', a => [a.model]),
|
|
92
|
+
mf('meshforge_import_model', 'Add a .glb from the local disk to the library and open it.', object({ path: str, name: str }, ['path'])),
|
|
93
|
+
mf('meshforge_generate', 'Image → textured 3D model on the user\'s own fal.ai key (PAID unless MeshForge is in demo mode: ask the user first and pass confirmCost:true). image: a local PNG/JPEG/WebP path. Then meshforge_wait.', object({ image: str, model: str, options: { type: 'object' }, confirmCost: bool }, ['image'])),
|
|
94
|
+
mf('meshforge_wait', 'Wait for a generation job to finish (up to timeout seconds), then meshforge_open_model.', object({ id: str, timeout: integer(5, 900) }, ['id']), 'waitForModel', a => [a.id, { timeout: a.timeout }]),
|
|
95
|
+
mf('meshforge_skeleton', 'The open model\'s bones: name, parent, role, joint position and rest aim, in character space (+x right, +y up from the floor, +z forward; metres).', object(), 'getSkeleton'),
|
|
96
|
+
mf('meshforge_animation_guide', 'How to write an animation spec (aim/rot/root/ease, grounding, anatomy, timing) plus the skeleton. Read before meshforge_build_animation.', object(), 'animationGuide'),
|
|
97
|
+
mf('meshforge_build_animation', 'Keyframe spec → animation on the open rig, played at once. Bones take {aim:[x,y,z], twist} (point the bone that way, character space), {rot:{yaw,pitch,roll}} (degrees from rest) or {rest:true}. root.move [x,y,z] metres (feet are kept on the floor; y = jump height). Returns warnings and measurements. replace:true overwrites a clip of the same name.', object({
|
|
98
|
+
spec: object({ name: str, duration: number(0.05, 60), loop: bool, fps: integer(5, 60), ground: { enum: ['auto', 'none'] }, keys: { type: 'array', minItems: 1, maxItems: 500, items: mfKey } }, ['name', 'duration', 'keys']), replace: bool }, ['spec']), 'buildAnimation', a => [a.spec, { replace: a.replace }]),
|
|
99
|
+
mf('meshforge_contact_sheet', 'Frames of an animation from the front and side (or other views) in one labelled image: the way to judge motion.', object({ animation: str, samples: integer(2, 16), views, size: integer(96, 384) }), 'renderContactSheet', a => [a]),
|
|
100
|
+
mf('meshforge_render_frame', 'One picture of the open model, optionally at a time in an animation, from a view (default threeQuarter).', object({ animation: str, time: number(0, 60), view: { enum: ['front', 'threeQuarter', 'side', 'right', 'left', 'back', 'top'] }, size: integer(128, 1024) }), 'renderFrame', a => [a]),
|
|
101
|
+
mf('meshforge_inspect', 'Numbers for an animation: feet vs floor, foot sliding, loop gap, which bones move, hips travel.', object({ animation: str, samples: integer(2, 120) }, ['animation']), 'inspectAnimation', a => [a.animation, { samples: a.samples }]),
|
|
102
|
+
mf('meshforge_start_rig', 'Rig the open model in the browser (free): a template skeleton (humanoid, quadruped or chain) fitted to the mesh. facing: which way the model looks in its own space. Then meshforge_render_rig.', object({ template: { enum: ['humanoid', 'quadruped', 'chain'] }, facing: { enum: ['+z', '-z', '+x', '-x'] } }), 'startRig', a => [a]),
|
|
103
|
+
mf('meshforge_render_rig', 'The model from the front and side with the draft joints drawn on it over a measuring grid (read coordinates off it). Props show purple; pass select to preview a selection in orange.', object({ views, size: integer(256, 1024), select: shape }), 'renderRig', a => [a]),
|
|
104
|
+
mf('meshforge_set_joints', 'Move draft joints: {jointName: [x, y, z]} in character space. mirror (default true) moves the left/right twin too. Joints belong INSIDE the body.', object({ joints: { type: 'object', minProperties: 1, additionalProperties: vec3 }, mirror: bool }, ['joints']), 'setRigJoints', a => [a.joints, { mirror: a.mirror }]),
|
|
105
|
+
mf('meshforge_add_prop', 'Give a held prop (axe, sword, shield, staff) its own rigid bone under parent (usually a hand). Works on a rig draft or on a model already rigged (MeshForge or Meshy). The selection grows along the prop automatically; check with meshforge_render_rig (draft) or meshforge_contact_sheet.', object({ name: str, parent: str, select: shape, grow: bool }, ['parent', 'select']), 'addProp', a => [a]),
|
|
106
|
+
mf('meshforge_bind_rig', 'Build the bones and automatic skin weights from the draft. Existing animations are retargeted where the skeletons can be matched. Then test with meshforge_build_animation.', object({ smooth: integer(0, 6) }), 'bindRig', a => [a]),
|
|
107
|
+
mf('meshforge_save', 'Save the open model (rig and animations) to the MeshForge library. A model saved from MeshForge before is updated; a generated or imported one gets a new entry.', object({ name: str, asNew: bool }), 'saveProject', a => [a]),
|
|
108
|
+
mf('meshforge_export', 'Write the open model with every animation as a .glb to disk.', object({ format: { enum: ['glb'] } }), 'exportFile', a => [{ format: a.format || 'glb' }]),
|
|
109
|
+
mf('meshforge_screenshot', 'Show the MeshForge page. Prefer meshforge_contact_sheet or meshforge_render_rig to judge work.'),
|
|
110
|
+
mf('meshforge_open_result', 'Save (if changed) and show MeshForge in visible Chrome with the connector profile, so the user can keep working.'),
|
|
111
|
+
)
|
|
112
|
+
export const advertisedTools = definitions.map(({ method, args, target, ...definition }) => definition)
|