keyframe-mcp 0.2.1 → 0.3.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 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/), and rig, animate, play-test and export pixel-art sprites in [Sprite Puppeteer](https://www.keyframe.it.com/sprite-puppeteer), from any MCP client.
4
4
 
5
5
  ## Install
6
6
 
@@ -18,6 +18,10 @@ 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
+
21
25
  ## Workflow
22
26
 
23
27
  1. `keyframe_open` resumes a dedicated Chrome profile and waits for saved projects to restore.
@@ -58,6 +62,40 @@ Read `keyframe_session` and pass its `projectId` and `revision` in `expected` to
58
62
 
59
63
  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
64
 
65
+ ## Sprite Puppeteer
66
+
67
+ [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).
68
+
69
+ 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).
70
+ 2. `puppeteer_state` lists moves, camera sets and directions (or views), and settings.
71
+ 3. Judge motion with `puppeteer_contact_sheet {move:"walk"}`; pick angles with `puppeteer_set_view {set:"iso8", dir:"S"}`.
72
+ 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`.
73
+ 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.
74
+ 6. `puppeteer_open_result` shows the page in visible Chrome.
75
+
76
+ Example move for the sample golem (`puppeteer_open {sample:"golem"}` first):
77
+
78
+ ```json
79
+ {"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"}]}}
80
+ ```
81
+
82
+ | Tool | Purpose |
83
+ | --- | --- |
84
+ | `puppeteer_open` | Open Sprite Puppeteer, optionally with a sample |
85
+ | `puppeteer_methods` / `puppeteer_call` | Every scripting method, and a generic call |
86
+ | `puppeteer_state` | Character, moves, camera or views, settings |
87
+ | `puppeteer_play_move` | Show a move on the stage |
88
+ | `puppeteer_set_view` | Camera set and direction (sheets), view (rigged), facing |
89
+ | `puppeteer_settings` | Background, fps, pixel scale, speed, effects, export options |
90
+ | `puppeteer_move_guide` / `puppeteer_create_move` | Pose channels and conventions; add or replace a keyframed move |
91
+ | `puppeteer_contact_sheet` / `puppeteer_render_frame` | Frames as images |
92
+ | `puppeteer_load_image` | Your sprite from disk as a new rigged character |
93
+ | `puppeteer_export` | Sheet, Godot, Unity, GameMaker, PNG frames, Spine, GIF or project file |
94
+ | `puppeteer_list_projects` / `puppeteer_open_project` / `puppeteer_save` | Projects in the connector profile |
95
+ | `puppeteer_screenshot` / `puppeteer_open_result` | The page, and handoff to visible Chrome |
96
+
97
+ The page's API is `window.spritePuppeteer`; any browser automation can call it directly. See [llms.txt](https://www.keyframe.it.com/llms.txt).
98
+
61
99
  ## Storage and handoff
62
100
 
63
101
  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 +113,4 @@ Close or Apply the human image editor before agent layer edits. Layers retain te
75
113
 
76
114
  `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
115
 
78
- MIT — see LICENSE. This license covers the connector; the editor is a separate hosted application.
116
+ 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.2.1",
3
+ "version": "0.3.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 by driving the free Keyframe.it editor in your browser.",
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, and rig, animate, play-test and export pixel-art sprites in Sprite Puppeteer, 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,17 @@ 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
+ }
18
29
  export class ToolRunner {
19
30
  constructor(session, outputDir = process.env.KEYFRAME_EXPORT_DIR || join(homedir(), '.keyframe-mcp', 'exports')) {
20
31
  this.session = session
@@ -32,6 +43,7 @@ export class ToolRunner {
32
43
  const definition = registered.get(name)
33
44
  if (!definition) throw new Error(`Unknown tool: ${name}`)
34
45
  if (!definition.validate(args)) throw new Error(`Invalid arguments: ${ajv.errorsText(definition.validate.errors)}`)
46
+ if (definition.target === 'puppeteer') return this.dispatchPuppeteer(name, definition, args)
35
47
  const page = await this.session.open(name === 'keyframe_open' ? args.url : undefined)
36
48
  if (name === 'keyframe_open_result') return toContent(await this.session.handoff())
37
49
  if (name === 'keyframe_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
@@ -46,6 +58,24 @@ export class ToolRunner {
46
58
  if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
47
59
  return toContent(result)
48
60
  }
61
+ /* Sprite Puppeteer: a second page in the same profile, driven through window.spritePuppeteer */
62
+ async dispatchPuppeteer(name, definition, args) {
63
+ const page = await this.session.openPuppeteer()
64
+ if (name === 'puppeteer_open_result') return toContent(await this.session.puppeteerHandoff())
65
+ if (name === 'puppeteer_screenshot') return { content: [{ type: 'image', mimeType: 'image/png', data: (await page.screenshot({ type: 'png' })).toString('base64') }] }
66
+ let method = definition.method, callArgs
67
+ if (name === 'puppeteer_call') { method = args.method; callArgs = args.args ?? [] }
68
+ else if (name === 'puppeteer_open') { method = args.sample ? 'loadSample' : 'getState'; callArgs = args.sample ? [args.sample] : [] }
69
+ else if (name === 'puppeteer_load_image') callArgs = [await artDataUrl(resolve(args.path)), { name: args.name || basename(args.path) }]
70
+ else callArgs = definition.args(args)
71
+ const result = await this.session.invokePuppeteer(method, callArgs)
72
+ if (result?.ok !== false) {
73
+ try { await this.session.savePuppeteer() }
74
+ 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 }) }
75
+ }
76
+ if (result?.artifact && result.ok !== false) return this.writeArtifact(result)
77
+ return toContent(name === 'puppeteer_open' ? { ...result, profile: this.session.profile, visible: !this.session.headless, handoffTool: 'puppeteer_open_result' } : result)
78
+ }
49
79
  async writeArtifact(result) {
50
80
  const artifact = result.artifact
51
81
  const match = artifact.dataUrl?.match(/^data:([^;,]*)(?:;[^,]*)?;base64,(.*)$/s)
@@ -57,7 +87,7 @@ export class ToolRunner {
57
87
  await writeFile(path, bytes, { flag: 'wx' })
58
88
  const resource = { uri: pathToFileURL(path).href, name: safeName, mimeType: artifact.mimeType || match[1], size: bytes.length }
59
89
  this.artifacts.set(resource.uri, { resource, path })
60
- const metadata = { ok: true, projectId: result.projectId, path, ...resource }
90
+ const metadata = { ok: true, projectId: result.projectId, path, ...resource, ...(result.detail ? { detail: result.detail } : {}) }
61
91
  return { content: [{ type: 'text', text: JSON.stringify(metadata) }, { type: 'resource_link', ...resource }], structuredContent: metadata }
62
92
  }
63
93
  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.2.1' }, { capabilities: { tools: {}, resources: {} } })
13
+ const server = new Server({ name: 'keyframe-mcp', version: '0.3.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 two pages: the Keyframe.it editor and Sprite Puppeteer. */
8
18
  export class EditorSession {
9
19
  constructor(chromium, env = process.env) {
10
20
  this.chromium = chromium
@@ -13,14 +23,19 @@ 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
16
27
  }
17
- async open(url) {
18
- const target = url ? editorUrl(url) : this.url
19
- if (this.page && !this.page.isClosed() && this.page.url() !== target) await this.save()
28
+ async ensureContext() {
20
29
  if (!this.context) {
21
30
  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 })
31
+ this.context.on('close', () => { this.context = null; this.page = null; this.pp = null })
23
32
  }
33
+ return this.context
34
+ }
35
+ async open(url) {
36
+ const target = url ? editorUrl(url) : this.url
37
+ if (this.page && !this.page.isClosed() && this.page.url() !== target) await this.save()
38
+ await this.ensureContext()
24
39
  if (!this.page || this.page.isClosed()) this.page = this.context.pages().find(p => p.url() === target) ?? await this.context.newPage()
25
40
  if (this.page.url() !== target) await this.page.goto(target, { waitUntil: 'domcontentloaded' })
26
41
  this.url = target
@@ -30,14 +45,7 @@ export class EditorSession {
30
45
  this.url = this.page.url()
31
46
  return this.page
32
47
  }
33
- async invoke(method, args = []) {
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
- }
48
+ invoke(method, args = []) { return callApi(this.page, 'keyframe', method, args) }
41
49
  async save() {
42
50
  if (!this.page || this.page.isClosed()) return
43
51
  const saved = await this.invoke('saveProject')
@@ -51,5 +59,35 @@ export class EditorSession {
51
59
  await this.page.bringToFront()
52
60
  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
61
  }
54
- async close() { try { await this.save() } finally { await this.context?.close() } }
62
+
63
+ /* ---------- Sprite Puppeteer (same site, /sprite-puppeteer) ---------- */
64
+ puppeteerUrl() { return new URL('/sprite-puppeteer', this.url).href }
65
+ async openPuppeteer() {
66
+ await this.ensureContext()
67
+ const target = this.puppeteerUrl()
68
+ if (!this.pp || this.pp.isClosed()) this.pp = this.context.pages().find(p => p.url().startsWith(target)) ?? await this.context.newPage()
69
+ if (!this.pp.url().startsWith(target)) await this.pp.goto(target, { waitUntil: 'domcontentloaded' })
70
+ await this.pp.waitForFunction(() => !!globalThis.spritePuppeteer, undefined, { timeout: 60000 })
71
+ const status = await this.invokePuppeteer('ready')
72
+ if (status?.ok === false) throw new Error(status.error)
73
+ return this.pp
74
+ }
75
+ invokePuppeteer(method, args = []) { return callApi(this.pp, 'spritePuppeteer', method, args) }
76
+ /* save only when the character changed: a sample nobody touched stays a sample, as it does for people */
77
+ async savePuppeteer() {
78
+ if (!this.pp || this.pp.isClosed()) return
79
+ const info = await this.invokePuppeteer('sessionInfo')
80
+ if (!info?.dirty) return info
81
+ const saved = await this.invokePuppeteer('saveProject')
82
+ if (saved?.ok === false) throw new Error(saved.error)
83
+ return saved
84
+ }
85
+ async puppeteerHandoff() {
86
+ await this.openPuppeteer()
87
+ const saved = await this.savePuppeteer()
88
+ if (this.headless) { await this.context.close(); this.headless = false; await this.openPuppeteer() }
89
+ await this.pp.bringToFront()
90
+ 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.' }
91
+ }
92
+ async close() { try { await this.save(); await this.savePuppeteer() } finally { await this.context?.close() } }
55
93
  }
package/tools.mjs CHANGED
@@ -42,4 +42,36 @@ 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
- export const advertisedTools = definitions.map(({ method, args, ...definition }) => definition)
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" (sprite sheets: side, isometric, top-down) or "golem" (rigged). Returns the character state. Call first.', object({ sample: { enum: ['orc', '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
+ export const advertisedTools = definitions.map(({ method, args, target, ...definition }) => definition)