@excalimate/mcp-server 0.2.0 → 0.3.5
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 +22 -1
- package/dist/checkpoint-store.d.ts +1 -0
- package/dist/checkpoint-store.d.ts.map +1 -1
- package/dist/checkpoint-store.js +23 -2
- package/dist/checkpoint-store.js.map +1 -1
- package/dist/httpServer.d.ts +4 -0
- package/dist/httpServer.d.ts.map +1 -0
- package/dist/httpServer.js +179 -0
- package/dist/httpServer.js.map +1 -0
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -197
- package/dist/index.js.map +1 -1
- package/dist/server/animationTools.d.ts +9 -0
- package/dist/server/animationTools.d.ts.map +1 -0
- package/dist/server/animationTools.js +254 -0
- package/dist/server/animationTools.js.map +1 -0
- package/dist/server/checkpointTools.d.ts +5 -0
- package/dist/server/checkpointTools.d.ts.map +1 -0
- package/dist/server/checkpointTools.js +22 -0
- package/dist/server/checkpointTools.js.map +1 -0
- package/dist/server/elementNormalizer.d.ts +3 -0
- package/dist/server/elementNormalizer.d.ts.map +1 -0
- package/dist/server/elementNormalizer.js +52 -0
- package/dist/server/elementNormalizer.js.map +1 -0
- package/dist/server/geometry.d.ts +24 -0
- package/dist/server/geometry.d.ts.map +1 -0
- package/dist/server/geometry.js +102 -0
- package/dist/server/geometry.js.map +1 -0
- package/dist/server/queryTools.d.ts +28 -0
- package/dist/server/queryTools.d.ts.map +1 -0
- package/dist/server/queryTools.js +107 -0
- package/dist/server/queryTools.js.map +1 -0
- package/dist/server/referenceText.d.ts +3 -0
- package/dist/server/referenceText.d.ts.map +1 -0
- package/dist/server/referenceText.js +268 -0
- package/dist/server/referenceText.js.map +1 -0
- package/dist/server/sceneTools.d.ts +4 -0
- package/dist/server/sceneTools.d.ts.map +1 -0
- package/dist/server/sceneTools.js +86 -0
- package/dist/server/sceneTools.js.map +1 -0
- package/dist/server/shareTools.d.ts +14 -0
- package/dist/server/shareTools.d.ts.map +1 -0
- package/dist/server/shareTools.js +81 -0
- package/dist/server/shareTools.js.map +1 -0
- package/dist/server/stateContext.d.ts +21 -0
- package/dist/server/stateContext.d.ts.map +1 -0
- package/dist/server/stateContext.js +85 -0
- package/dist/server/stateContext.js.map +1 -0
- package/dist/server.d.ts +4 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +23 -906
- package/dist/server.js.map +1 -1
- package/dist/stdioServer.d.ts +3 -0
- package/dist/stdioServer.d.ts.map +1 -0
- package/dist/stdioServer.js +5 -0
- package/dist/stdioServer.js.map +1 -0
- package/package.json +3 -2
package/dist/server.js
CHANGED
|
@@ -1,912 +1,29 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
/**
|
|
3
|
-
* Excalimate MCP Server
|
|
4
|
-
*
|
|
5
|
-
* Provides tools for creating Excalidraw scenes, animating them with keyframes,
|
|
6
|
-
* and exporting the results. Designed for AI agent integration via MCP.
|
|
7
|
-
*/
|
|
8
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
-
import { z } from 'zod';
|
|
10
2
|
import { nanoid } from 'nanoid';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const { version: PKG_VERSION } = require('../package.json');
|
|
6
|
+
import { normalizeElements } from './server/elementNormalizer.js';
|
|
7
|
+
import { getElementBounds } from './server/geometry.js';
|
|
8
|
+
import * as geometry from './server/geometry.js';
|
|
9
|
+
import { registerAnimationTools } from './server/animationTools.js';
|
|
10
|
+
import { registerCheckpointTools } from './server/checkpointTools.js';
|
|
11
|
+
import { registerQueryTools } from './server/queryTools.js';
|
|
12
|
+
import { registerShareTools } from './server/shareTools.js';
|
|
13
|
+
import { REFERENCE_TEXT, EXAMPLES_TEXT } from './server/referenceText.js';
|
|
14
|
+
import { registerSceneTools } from './server/sceneTools.js';
|
|
15
|
+
import { createStateContext, getSharedState } from './server/stateContext.js';
|
|
16
|
+
export { getSharedState };
|
|
16
17
|
export function createServer(store, onStateChange) {
|
|
17
|
-
const server = new McpServer({
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
catch (err) {
|
|
27
|
-
console.error('emitChange failed:', err);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
server.__getState = () => _sharedState;
|
|
31
|
-
function updateState(newState) { _sharedState = newState; }
|
|
32
|
-
// Read-only tools use server.tool directly.
|
|
33
|
-
// Mutating tools use this wrapper that auto-emits state changes.
|
|
34
|
-
function mutatingTool(name, description, schema, handler) {
|
|
35
|
-
server.tool(name, description, schema, async (args) => {
|
|
36
|
-
const result = await handler(args);
|
|
37
|
-
emitChange();
|
|
38
|
-
return result;
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
// ── REFERENCE TOOL ──────────────────────────────────────────────
|
|
42
|
-
server.tool('read_me', 'Returns the Excalidraw element format reference, animation property docs, easing types, and usage examples. Call this FIRST before creating scenes or animations.', {}, async () => ({
|
|
43
|
-
content: [{
|
|
44
|
-
type: 'text',
|
|
45
|
-
text: REFERENCE_TEXT,
|
|
46
|
-
}],
|
|
47
|
-
}));
|
|
48
|
-
server.tool('get_examples', 'Returns few-shot examples showing how to create elements and animate them. Call this to learn common patterns.', {}, async () => ({
|
|
49
|
-
content: [{
|
|
50
|
-
type: 'text',
|
|
51
|
-
text: EXAMPLES_TEXT,
|
|
52
|
-
}],
|
|
53
|
-
}));
|
|
54
|
-
// ── Element Normalizer ────────────────────────────────────────────
|
|
55
|
-
/** Counter for generating fractional indices for elements */
|
|
56
|
-
let _elementIndexCounter = 0;
|
|
57
|
-
/** Fill in default Excalidraw properties so elements render correctly */
|
|
58
|
-
function normalizeElement(el) {
|
|
59
|
-
// Generate a fractional index if missing. Excalidraw v0.18+ requires
|
|
60
|
-
// the `index` property for element ordering on the canvas. Without it,
|
|
61
|
-
// api.updateScene() accepts elements into React state but the canvas
|
|
62
|
-
// renderer silently skips them.
|
|
63
|
-
const index = el.index ?? `a${_elementIndexCounter++}`;
|
|
64
|
-
return {
|
|
65
|
-
// Required base properties with defaults
|
|
66
|
-
angle: 0,
|
|
67
|
-
strokeColor: '#1e1e1e',
|
|
68
|
-
backgroundColor: 'transparent',
|
|
69
|
-
fillStyle: 'solid',
|
|
70
|
-
strokeWidth: 2,
|
|
71
|
-
strokeStyle: 'solid',
|
|
72
|
-
roughness: 1,
|
|
73
|
-
groupIds: [],
|
|
74
|
-
frameId: null,
|
|
75
|
-
index,
|
|
76
|
-
roundness: null,
|
|
77
|
-
boundElements: null,
|
|
78
|
-
updated: Date.now(),
|
|
79
|
-
link: null,
|
|
80
|
-
locked: false,
|
|
81
|
-
isDeleted: false,
|
|
82
|
-
// Text-specific defaults
|
|
83
|
-
...(el.type === 'text' ? {
|
|
84
|
-
fontSize: 20,
|
|
85
|
-
fontFamily: 5,
|
|
86
|
-
textAlign: 'left',
|
|
87
|
-
verticalAlign: 'top',
|
|
88
|
-
lineHeight: 1.25,
|
|
89
|
-
baseline: 0,
|
|
90
|
-
containerId: null,
|
|
91
|
-
originalText: el.text ?? '',
|
|
92
|
-
autoResize: true,
|
|
93
|
-
} : {}),
|
|
94
|
-
// Arrow/line defaults
|
|
95
|
-
...(el.type === 'arrow' || el.type === 'line' ? {
|
|
96
|
-
points: el.points ?? [[0, 0], [el.width ?? 100, el.height ?? 0]],
|
|
97
|
-
startBinding: null,
|
|
98
|
-
endBinding: null,
|
|
99
|
-
startArrowhead: null,
|
|
100
|
-
endArrowhead: el.type === 'arrow' ? 'arrow' : null,
|
|
101
|
-
lastCommittedPoint: null,
|
|
102
|
-
} : {}),
|
|
103
|
-
// Override with user-provided values
|
|
104
|
-
...el,
|
|
105
|
-
// Always force opacity to 100 — animation keyframes control visibility via multiplier.
|
|
106
|
-
opacity: 100,
|
|
107
|
-
// Text elements: force autoResize so Excalidraw computes proper width
|
|
108
|
-
...(el.type === 'text' && !el.containerId ? { autoResize: true } : {}),
|
|
109
|
-
// Always generate seed/version if missing
|
|
110
|
-
seed: el.seed ?? (Math.random() * 2147483647 | 0),
|
|
111
|
-
version: el.version ?? 1,
|
|
112
|
-
versionNonce: el.versionNonce ?? (Math.random() * 2147483647 | 0),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
function normalizeElements(elements) {
|
|
116
|
-
return elements.map(normalizeElement);
|
|
117
|
-
}
|
|
118
|
-
// ── SCENE TOOLS ─────────────────────────────────────────────────
|
|
119
|
-
mutatingTool('create_scene', 'Create or replace the Excalidraw scene with the given elements.', { elements: z.string().describe('JSON string of Excalidraw elements array') }, async ({ elements }) => {
|
|
120
|
-
try {
|
|
121
|
-
const parsed = JSON.parse(elements);
|
|
122
|
-
if (!Array.isArray(parsed))
|
|
123
|
-
return { content: [{ type: 'text', text: 'Error: elements must be a JSON array' }] };
|
|
124
|
-
_sharedState.scene.elements = normalizeElements(parsed);
|
|
125
|
-
return { content: [{ type: 'text', text: `Scene created with ${parsed.length} elements.` }] };
|
|
126
|
-
}
|
|
127
|
-
catch (e) {
|
|
128
|
-
return { content: [{ type: 'text', text: `Error parsing elements: ${e}` }] };
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
mutatingTool('add_elements', 'Add elements to the existing scene.', { elements: z.string().describe('JSON string of elements to add') }, async ({ elements }) => {
|
|
132
|
-
try {
|
|
133
|
-
const parsed = JSON.parse(elements);
|
|
134
|
-
if (!Array.isArray(parsed))
|
|
135
|
-
return { content: [{ type: 'text', text: 'Error: must be array' }] };
|
|
136
|
-
_sharedState.scene.elements.push(...normalizeElements(parsed));
|
|
137
|
-
return { content: [{ type: 'text', text: `Added ${parsed.length} elements. Total: ${_sharedState.scene.elements.length}.` }] };
|
|
138
|
-
}
|
|
139
|
-
catch (e) {
|
|
140
|
-
return { content: [{ type: 'text', text: `Error: ${e}` }] };
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
mutatingTool('remove_elements', 'Remove elements by their IDs.', { ids: z.array(z.string()).describe('Array of element IDs to remove') }, async ({ ids }) => {
|
|
144
|
-
const before = _sharedState.scene.elements.length;
|
|
145
|
-
_sharedState.scene.elements = _sharedState.scene.elements.filter((el) => !ids.includes(el.id));
|
|
146
|
-
const removed = before - _sharedState.scene.elements.length;
|
|
147
|
-
return { content: [{ type: 'text', text: `Removed ${removed} elements. Total: ${_sharedState.scene.elements.length}.` }] };
|
|
148
|
-
});
|
|
149
|
-
mutatingTool('update_elements', 'Update properties of existing elements.', { updates: z.string().describe('JSON string of array [{id, ...properties}]') }, async ({ updates }) => {
|
|
150
|
-
try {
|
|
151
|
-
const parsed = JSON.parse(updates);
|
|
152
|
-
if (!Array.isArray(parsed))
|
|
153
|
-
return { content: [{ type: 'text', text: 'Error: must be array' }] };
|
|
154
|
-
let updated = 0;
|
|
155
|
-
for (const upd of parsed) {
|
|
156
|
-
const idx = _sharedState.scene.elements.findIndex((el) => el.id === upd.id);
|
|
157
|
-
if (idx >= 0) {
|
|
158
|
-
_sharedState.scene.elements[idx] = { ..._sharedState.scene.elements[idx], ...upd };
|
|
159
|
-
updated++;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return { content: [{ type: 'text', text: `Updated ${updated} elements.` }] };
|
|
163
|
-
}
|
|
164
|
-
catch (e) {
|
|
165
|
-
return { content: [{ type: 'text', text: `Error: ${e}` }] };
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
server.tool('get_scene', 'Return the current scene elements as JSON.', {}, async () => ({
|
|
169
|
-
content: [{ type: 'text', text: JSON.stringify(_sharedState.scene.elements, null, 2) }],
|
|
170
|
-
}));
|
|
171
|
-
// ── ANIMATION TOOLS ─────────────────────────────────────────────
|
|
172
|
-
mutatingTool('add_keyframe', 'Add a keyframe to an animation track. Auto-creates the track if it doesn\'t exist.', {
|
|
173
|
-
targetId: z.string().describe('Element or group ID'),
|
|
174
|
-
property: z.enum(ANIMATABLE_PROPERTIES).describe('Animatable property'),
|
|
175
|
-
time: z.number().min(0).describe('Time in milliseconds'),
|
|
176
|
-
value: z.number().describe('Property value at this time'),
|
|
177
|
-
easing: z.enum(EASING_TYPES).optional().describe('Easing to next keyframe'),
|
|
178
|
-
}, async ({ targetId, property, time, value, easing }) => {
|
|
179
|
-
updateState(addKeyframeToState(_sharedState, targetId, property, time, value, easing ?? 'linear'));
|
|
180
|
-
return { content: [{ type: 'text', text: `Keyframe added: ${property} = ${value} at ${time}ms for ${targetId}` }] };
|
|
181
|
-
});
|
|
182
|
-
mutatingTool('add_keyframes_batch', 'Add multiple keyframes at once. For scaleX/scaleY keyframes, include a "scaleOrigin" field per keyframe to control where scaling anchors from (auto-adds translate compensation). Origins: center, top-left, top-right, bottom-left, bottom-right, top, bottom, left, right.', {
|
|
183
|
-
keyframes: z.string().describe('JSON array of {targetId, property, time, value, easing?, scaleOrigin?}'),
|
|
184
|
-
}, async ({ keyframes }) => {
|
|
185
|
-
try {
|
|
186
|
-
const parsed = JSON.parse(keyframes);
|
|
187
|
-
if (!Array.isArray(parsed))
|
|
188
|
-
return { content: [{ type: 'text', text: 'Error: must be array' }] };
|
|
189
|
-
const originMap = {
|
|
190
|
-
'top-left': [0, 0], 'top': [0.5, 0], 'top-right': [1, 0],
|
|
191
|
-
'left': [0, 0.5], 'center': [0.5, 0.5], 'right': [1, 0.5],
|
|
192
|
-
'bottom-left': [0, 1], 'bottom': [0.5, 1], 'bottom-right': [1, 1],
|
|
193
|
-
};
|
|
194
|
-
// First pass: collect scale keyframes that have scaleOrigin, grouped by targetId+time
|
|
195
|
-
const scaleCompensation = new Map();
|
|
196
|
-
for (const kf of parsed) {
|
|
197
|
-
if ((kf.property === 'scaleX' || kf.property === 'scaleY') && kf.scaleOrigin && kf.scaleOrigin !== 'top-left') {
|
|
198
|
-
const key = `${kf.targetId}@${kf.time}`;
|
|
199
|
-
const existing = scaleCompensation.get(key) ?? { targetId: kf.targetId, time: kf.time, sx: 1, sy: 1, origin: kf.scaleOrigin, easing: kf.easing ?? 'linear' };
|
|
200
|
-
if (kf.property === 'scaleX')
|
|
201
|
-
existing.sx = kf.value;
|
|
202
|
-
if (kf.property === 'scaleY')
|
|
203
|
-
existing.sy = kf.value;
|
|
204
|
-
existing.origin = kf.scaleOrigin;
|
|
205
|
-
scaleCompensation.set(key, existing);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// Second pass: add all keyframes
|
|
209
|
-
const validProperties = new Set(ANIMATABLE_PROPERTIES);
|
|
210
|
-
let count = 0;
|
|
211
|
-
let skipped = 0;
|
|
212
|
-
for (const kf of parsed) {
|
|
213
|
-
if (!validProperties.has(kf.property)) {
|
|
214
|
-
skipped++;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
updateState(addKeyframeToState(_sharedState, kf.targetId, kf.property, kf.time, kf.value, kf.easing ?? 'linear'));
|
|
218
|
-
count++;
|
|
219
|
-
}
|
|
220
|
-
// Third pass: add translate compensation for scale keyframes with origins
|
|
221
|
-
for (const skf of scaleCompensation.values()) {
|
|
222
|
-
const [ox, oy] = originMap[skf.origin] ?? [0.5, 0.5];
|
|
223
|
-
const el = _sharedState.scene.elements.find((e) => e.id === skf.targetId);
|
|
224
|
-
if (!el)
|
|
225
|
-
continue;
|
|
226
|
-
const bounds = getElementBounds(el);
|
|
227
|
-
const w = bounds.maxX - bounds.minX;
|
|
228
|
-
const h = bounds.maxY - bounds.minY;
|
|
229
|
-
const tx = -w * (skf.sx - 1) * ox;
|
|
230
|
-
const ty = -h * (skf.sy - 1) * oy;
|
|
231
|
-
updateState(addKeyframeToState(_sharedState, skf.targetId, 'translateX', skf.time, tx, skf.easing));
|
|
232
|
-
updateState(addKeyframeToState(_sharedState, skf.targetId, 'translateY', skf.time, ty, skf.easing));
|
|
233
|
-
count += 2;
|
|
234
|
-
}
|
|
235
|
-
return { content: [{ type: 'text', text: `Added ${count} keyframes.${skipped ? ` Skipped ${skipped} with invalid properties.` : ''}` }] };
|
|
236
|
-
}
|
|
237
|
-
catch (e) {
|
|
238
|
-
return { content: [{ type: 'text', text: `Error: ${e}` }] };
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
mutatingTool('remove_keyframe', 'Remove a keyframe by track and keyframe ID.', {
|
|
242
|
-
trackId: z.string().describe('Track ID'),
|
|
243
|
-
keyframeId: z.string().describe('Keyframe ID'),
|
|
244
|
-
}, async ({ trackId, keyframeId }) => {
|
|
245
|
-
const track = _sharedState.timeline.tracks.find(t => t.id === trackId);
|
|
246
|
-
if (!track)
|
|
247
|
-
return { content: [{ type: 'text', text: 'Track not found.' }] };
|
|
248
|
-
const before = track.keyframes.length;
|
|
249
|
-
track.keyframes = track.keyframes.filter(kf => kf.id !== keyframeId);
|
|
250
|
-
return { content: [{ type: 'text', text: `Removed ${before - track.keyframes.length} keyframe(s).` }] };
|
|
251
|
-
});
|
|
252
|
-
mutatingTool('create_sequence', 'Create a reveal sequence — elements appear one after another with configurable timing.', {
|
|
253
|
-
elementIds: z.array(z.string()).describe('Element IDs in reveal order'),
|
|
254
|
-
property: z.enum(['opacity', 'drawProgress']).default('opacity').describe('Property to animate'),
|
|
255
|
-
startTime: z.number().min(0).default(0).describe('When sequence starts (ms)'),
|
|
256
|
-
delay: z.number().min(0).default(300).describe('Delay between each reveal (ms)'),
|
|
257
|
-
duration: z.number().min(50).default(500).describe('Duration of each reveal (ms)'),
|
|
258
|
-
}, async ({ elementIds, property, startTime, delay, duration }) => {
|
|
259
|
-
for (let i = 0; i < elementIds.length; i++) {
|
|
260
|
-
const revealStart = startTime + i * delay;
|
|
261
|
-
const revealEnd = revealStart + duration;
|
|
262
|
-
const targetId = elementIds[i];
|
|
263
|
-
if (revealStart > 0) {
|
|
264
|
-
updateState(addKeyframeToState(_sharedState, targetId, property, 0, 0));
|
|
265
|
-
}
|
|
266
|
-
if (revealStart > 10) {
|
|
267
|
-
updateState(addKeyframeToState(_sharedState, targetId, property, revealStart, 0));
|
|
268
|
-
}
|
|
269
|
-
updateState(addKeyframeToState(_sharedState, targetId, property, revealEnd, 1, 'easeOut'));
|
|
270
|
-
}
|
|
271
|
-
const totalDuration = startTime + (elementIds.length - 1) * delay + duration;
|
|
272
|
-
return { content: [{ type: 'text', text: `Sequence created: ${elementIds.length} elements, total ${totalDuration}ms.` }] };
|
|
273
|
-
});
|
|
274
|
-
mutatingTool('set_clip_range', 'Set the export clip start and end times.', {
|
|
275
|
-
start: z.number().min(0).describe('Clip start time (ms)'),
|
|
276
|
-
end: z.number().min(100).describe('Clip end time (ms)'),
|
|
277
|
-
}, async ({ start, end }) => {
|
|
278
|
-
_sharedState.clipStart = start;
|
|
279
|
-
_sharedState.clipEnd = Math.max(start + 100, end);
|
|
280
|
-
return { content: [{ type: 'text', text: `Clip range: ${start}ms – ${end}ms (${(end - start) / 1000}s)` }] };
|
|
281
|
-
});
|
|
282
|
-
server.tool('get_timeline', 'Return the current animation timeline as JSON.', {}, async () => ({
|
|
283
|
-
content: [{
|
|
284
|
-
type: 'text',
|
|
285
|
-
text: JSON.stringify({
|
|
286
|
-
timeline: _sharedState.timeline,
|
|
287
|
-
clipStart: _sharedState.clipStart,
|
|
288
|
-
clipEnd: _sharedState.clipEnd,
|
|
289
|
-
cameraFrame: _sharedState.cameraFrame,
|
|
290
|
-
}, null, 2),
|
|
291
|
-
}],
|
|
292
|
-
}));
|
|
293
|
-
mutatingTool('clear_animation', 'Clear all animation tracks.', {}, async () => {
|
|
294
|
-
_sharedState.timeline.tracks = [];
|
|
295
|
-
return { content: [{ type: 'text', text: 'All animation tracks cleared.' }] };
|
|
296
|
-
});
|
|
297
|
-
mutatingTool('clear_scene', 'Clear all elements and all animation tracks. Resets the scene to a blank canvas.', {}, async () => {
|
|
298
|
-
_sharedState.scene.elements = [];
|
|
299
|
-
_sharedState.scene.files = {};
|
|
300
|
-
_sharedState.timeline.tracks = [];
|
|
301
|
-
return { content: [{ type: 'text', text: 'Scene and all animations cleared.' }] };
|
|
302
|
-
});
|
|
303
|
-
mutatingTool('add_scale_animation', 'Add scale keyframes with a specific origin (edge/corner/center). Auto-computes translate compensation to keep the origin point fixed during scaling.', {
|
|
304
|
-
targetId: z.string().describe('Element ID'),
|
|
305
|
-
origin: z.enum(['center', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top', 'bottom', 'left', 'right']).describe('Scale origin point'),
|
|
306
|
-
keyframes: z.string().describe('JSON array of {time, scaleX, scaleY, easing?}'),
|
|
307
|
-
}, async ({ targetId, origin, keyframes }) => {
|
|
308
|
-
try {
|
|
309
|
-
const parsed = JSON.parse(keyframes);
|
|
310
|
-
if (!Array.isArray(parsed))
|
|
311
|
-
return { content: [{ type: 'text', text: 'Error: must be array' }] };
|
|
312
|
-
const el = _sharedState.scene.elements.find((e) => e.id === targetId);
|
|
313
|
-
if (!el)
|
|
314
|
-
return { content: [{ type: 'text', text: `Element "${targetId}" not found.` }] };
|
|
315
|
-
const bounds = getElementBounds(el);
|
|
316
|
-
const w = bounds.maxX - bounds.minX;
|
|
317
|
-
const h = bounds.maxY - bounds.minY;
|
|
318
|
-
// Origin multipliers: how much of the size change to compensate via translate
|
|
319
|
-
// top-left: (0, 0) — no compensation needed (native behavior)
|
|
320
|
-
// center: (0.5, 0.5) — compensate half in both directions
|
|
321
|
-
// bottom-right: (1, 1) — compensate full width/height change
|
|
322
|
-
const originMap = {
|
|
323
|
-
'top-left': [0, 0],
|
|
324
|
-
'top': [0.5, 0],
|
|
325
|
-
'top-right': [1, 0],
|
|
326
|
-
'left': [0, 0.5],
|
|
327
|
-
'center': [0.5, 0.5],
|
|
328
|
-
'right': [1, 0.5],
|
|
329
|
-
'bottom-left': [0, 1],
|
|
330
|
-
'bottom': [0.5, 1],
|
|
331
|
-
'bottom-right': [1, 1],
|
|
332
|
-
};
|
|
333
|
-
const [ox, oy] = originMap[origin] ?? [0.5, 0.5];
|
|
334
|
-
let count = 0;
|
|
335
|
-
for (const kf of parsed) {
|
|
336
|
-
const sx = kf.scaleX ?? 1;
|
|
337
|
-
const sy = kf.scaleY ?? 1;
|
|
338
|
-
const easing = kf.easing ?? 'linear';
|
|
339
|
-
// Add scale keyframes
|
|
340
|
-
updateState(addKeyframeToState(_sharedState, targetId, 'scaleX', kf.time, sx, easing));
|
|
341
|
-
updateState(addKeyframeToState(_sharedState, targetId, 'scaleY', kf.time, sy, easing));
|
|
342
|
-
// Compute translate compensation to keep origin fixed
|
|
343
|
-
// When scaling from top-left (ox=0): no translate needed
|
|
344
|
-
// When scaling from center (ox=0.5): tx = -w * (sx - 1) * 0.5
|
|
345
|
-
// When scaling from right (ox=1): tx = -w * (sx - 1)
|
|
346
|
-
const tx = -w * (sx - 1) * ox;
|
|
347
|
-
const ty = -h * (sy - 1) * oy;
|
|
348
|
-
if (Math.abs(tx) > 0.1 || Math.abs(ty) > 0.1 || ox !== 0 || oy !== 0) {
|
|
349
|
-
updateState(addKeyframeToState(_sharedState, targetId, 'translateX', kf.time, tx, easing));
|
|
350
|
-
updateState(addKeyframeToState(_sharedState, targetId, 'translateY', kf.time, ty, easing));
|
|
351
|
-
}
|
|
352
|
-
count++;
|
|
353
|
-
}
|
|
354
|
-
return { content: [{ type: 'text', text: `Added ${count} scale keyframes with origin "${origin}" for "${targetId}".` }] };
|
|
355
|
-
}
|
|
356
|
-
catch (e) {
|
|
357
|
-
return { content: [{ type: 'text', text: `Error: ${e}` }] };
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
// ── CAMERA TOOLS ────────────────────────────────────────────────
|
|
361
|
-
mutatingTool('set_camera_frame', 'Set the camera frame position, size, and aspect ratio. Also creates initial keyframes at time 0 for translateX, translateY, scaleX, scaleY so the camera starts at this position.', {
|
|
362
|
-
x: z.number().optional().describe('Camera center X (scene coords)'),
|
|
363
|
-
y: z.number().optional().describe('Camera center Y (scene coords)'),
|
|
364
|
-
width: z.number().optional().describe('Camera width (scene units)'),
|
|
365
|
-
aspectRatio: z.enum(['16:9', '4:3', '1:1', '3:2']).optional().describe('Aspect ratio'),
|
|
366
|
-
}, async ({ x, y, width, aspectRatio }) => {
|
|
367
|
-
if (x !== undefined)
|
|
368
|
-
_sharedState.cameraFrame.x = x;
|
|
369
|
-
if (y !== undefined)
|
|
370
|
-
_sharedState.cameraFrame.y = y;
|
|
371
|
-
if (width !== undefined)
|
|
372
|
-
_sharedState.cameraFrame.width = width;
|
|
373
|
-
if (aspectRatio !== undefined)
|
|
374
|
-
_sharedState.cameraFrame.aspectRatio = aspectRatio;
|
|
375
|
-
// Create initial camera keyframes at t=0 so camera starts at the defined position
|
|
376
|
-
const CAMERA_ID = '__camera_frame__';
|
|
377
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, 'translateX', 0, 0));
|
|
378
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, 'translateY', 0, 0));
|
|
379
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, 'scaleX', 0, 1));
|
|
380
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, 'scaleY', 0, 1));
|
|
381
|
-
return { content: [{ type: 'text', text: `Camera: ${_sharedState.cameraFrame.aspectRatio} at (${_sharedState.cameraFrame.x}, ${_sharedState.cameraFrame.y}), width ${_sharedState.cameraFrame.width}. Initial keyframes created at t=0.` }] };
|
|
382
|
-
});
|
|
383
|
-
mutatingTool('add_camera_keyframe', 'Add a keyframe for camera pan/zoom animation.', {
|
|
384
|
-
property: z.enum(['translateX', 'translateY', 'scaleX', 'scaleY']).describe('Camera property'),
|
|
385
|
-
time: z.number().min(0).describe('Time in ms'),
|
|
386
|
-
value: z.number().describe('Value'),
|
|
387
|
-
easing: z.enum(EASING_TYPES).optional(),
|
|
388
|
-
}, async ({ property, time, value, easing }) => {
|
|
389
|
-
const CAMERA_ID = '__camera_frame__';
|
|
390
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, property, time, value, easing ?? 'linear'));
|
|
391
|
-
return { content: [{ type: 'text', text: `Camera keyframe: ${property} = ${value} at ${time}ms` }] };
|
|
392
|
-
});
|
|
393
|
-
mutatingTool('add_camera_keyframes_batch', 'Add multiple camera keyframes at once. Properties: translateX, translateY, scaleX, scaleY.', {
|
|
394
|
-
keyframes: z.string().describe('JSON array of {property: "translateX"|"translateY"|"scaleX"|"scaleY", time, value, easing?}'),
|
|
395
|
-
}, async ({ keyframes }) => {
|
|
396
|
-
try {
|
|
397
|
-
const parsed = JSON.parse(keyframes);
|
|
398
|
-
if (!Array.isArray(parsed))
|
|
399
|
-
return { content: [{ type: 'text', text: 'Error: must be array' }] };
|
|
400
|
-
const CAMERA_ID = '__camera_frame__';
|
|
401
|
-
const validCameraProps = new Set(['translateX', 'translateY', 'scaleX', 'scaleY']);
|
|
402
|
-
// Map common mistakes: x→translateX, y→translateY, zoom→scaleX
|
|
403
|
-
const propMap = {
|
|
404
|
-
x: 'translateX', y: 'translateY',
|
|
405
|
-
panX: 'translateX', panY: 'translateY',
|
|
406
|
-
zoom: 'scaleX', scale: 'scaleX',
|
|
407
|
-
};
|
|
408
|
-
let count = 0;
|
|
409
|
-
let skipped = 0;
|
|
410
|
-
for (const kf of parsed) {
|
|
411
|
-
let prop = kf.property;
|
|
412
|
-
if (propMap[prop])
|
|
413
|
-
prop = propMap[prop];
|
|
414
|
-
if (!validCameraProps.has(prop)) {
|
|
415
|
-
skipped++;
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
updateState(addKeyframeToState(_sharedState, CAMERA_ID, prop, kf.time, kf.value, kf.easing ?? 'linear'));
|
|
419
|
-
count++;
|
|
420
|
-
}
|
|
421
|
-
return { content: [{ type: 'text', text: `Added ${count} camera keyframes.${skipped ? ` Skipped ${skipped} with invalid properties (use translateX, translateY, scaleX, scaleY).` : ''}` }] };
|
|
422
|
-
}
|
|
423
|
-
catch (e) {
|
|
424
|
-
return { content: [{ type: 'text', text: `Error: ${e}` }] };
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
// ── INSPECTION & VALIDATION TOOLS ────────────────────────────────
|
|
428
|
-
/**
|
|
429
|
-
* Interpolate a track's value at a given time (simplified linear interpolation).
|
|
430
|
-
*/
|
|
431
|
-
function interpolateTrackAt(track, time) {
|
|
432
|
-
const kfs = track.keyframes;
|
|
433
|
-
if (kfs.length === 0)
|
|
434
|
-
return PROPERTY_DEFAULTS[track.property];
|
|
435
|
-
if (time <= kfs[0].time)
|
|
436
|
-
return kfs[0].value;
|
|
437
|
-
if (time >= kfs[kfs.length - 1].time)
|
|
438
|
-
return kfs[kfs.length - 1].value;
|
|
439
|
-
for (let i = 0; i < kfs.length - 1; i++) {
|
|
440
|
-
if (time >= kfs[i].time && time <= kfs[i + 1].time) {
|
|
441
|
-
const t = (time - kfs[i].time) / (kfs[i + 1].time - kfs[i].time);
|
|
442
|
-
return kfs[i].value + (kfs[i + 1].value - kfs[i].value) * t;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
return kfs[kfs.length - 1].value;
|
|
446
|
-
}
|
|
447
|
-
/** Get element bounds (handles negative width/height and points). */
|
|
448
|
-
function getElementBounds(el) {
|
|
449
|
-
if (el.points?.length > 0) {
|
|
450
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
451
|
-
for (const [px, py] of el.points) {
|
|
452
|
-
const ax = el.x + px, ay = el.y + py;
|
|
453
|
-
if (ax < minX)
|
|
454
|
-
minX = ax;
|
|
455
|
-
if (ay < minY)
|
|
456
|
-
minY = ay;
|
|
457
|
-
if (ax > maxX)
|
|
458
|
-
maxX = ax;
|
|
459
|
-
if (ay > maxY)
|
|
460
|
-
maxY = ay;
|
|
461
|
-
}
|
|
462
|
-
return { minX, minY, maxX, maxY };
|
|
463
|
-
}
|
|
464
|
-
const x1 = Math.min(el.x, el.x + el.width);
|
|
465
|
-
const y1 = Math.min(el.y, el.y + el.height);
|
|
466
|
-
return { minX: x1, minY: y1, maxX: x1 + Math.abs(el.width), maxY: y1 + Math.abs(el.height) };
|
|
467
|
-
}
|
|
468
|
-
/** Get animated position of an element at a given time. */
|
|
469
|
-
function getAnimatedBoundsAt(elId, time) {
|
|
470
|
-
const el = _sharedState.scene.elements.find((e) => e.id === elId);
|
|
471
|
-
if (!el)
|
|
472
|
-
return null;
|
|
473
|
-
const base = getElementBounds(el);
|
|
474
|
-
const tracks = _sharedState.timeline.tracks.filter((t) => t.targetId === elId);
|
|
475
|
-
let tx = 0, ty = 0, sx = 1, sy = 1;
|
|
476
|
-
for (const track of tracks) {
|
|
477
|
-
const v = interpolateTrackAt(track, time);
|
|
478
|
-
if (track.property === 'translateX')
|
|
479
|
-
tx = v;
|
|
480
|
-
if (track.property === 'translateY')
|
|
481
|
-
ty = v;
|
|
482
|
-
if (track.property === 'scaleX')
|
|
483
|
-
sx = v;
|
|
484
|
-
if (track.property === 'scaleY')
|
|
485
|
-
sy = v;
|
|
486
|
-
}
|
|
487
|
-
const w = (base.maxX - base.minX) * sx;
|
|
488
|
-
const h = (base.maxY - base.minY) * sy;
|
|
489
|
-
return { minX: base.minX + tx, minY: base.minY + ty, maxX: base.minX + tx + w, maxY: base.minY + ty + h };
|
|
490
|
-
}
|
|
491
|
-
/** Get camera rect at a given time. */
|
|
492
|
-
function getCameraRectAt(time) {
|
|
493
|
-
const cf = _sharedState.cameraFrame;
|
|
494
|
-
const camTracks = _sharedState.timeline.tracks.filter((t) => t.targetId === '__camera_frame__');
|
|
495
|
-
let tx = 0, ty = 0, sx = 1, sy = 1;
|
|
496
|
-
for (const track of camTracks) {
|
|
497
|
-
const v = interpolateTrackAt(track, time);
|
|
498
|
-
if (track.property === 'translateX')
|
|
499
|
-
tx = v;
|
|
500
|
-
if (track.property === 'translateY')
|
|
501
|
-
ty = v;
|
|
502
|
-
if (track.property === 'scaleX')
|
|
503
|
-
sx = v;
|
|
504
|
-
if (track.property === 'scaleY')
|
|
505
|
-
sy = v;
|
|
506
|
-
}
|
|
507
|
-
const w = cf.width * sx;
|
|
508
|
-
const h = (cf.width / (ASPECT_RATIOS[cf.aspectRatio] ?? 16 / 9)) * sy;
|
|
509
|
-
const cx = cf.x + tx;
|
|
510
|
-
const cy = cf.y + ty;
|
|
511
|
-
return { left: cx - w / 2, top: cy - h / 2, right: cx + w / 2, bottom: cy + h / 2, cx, cy };
|
|
512
|
-
}
|
|
513
|
-
server.tool('are_items_in_line', 'Check if the given items are aligned horizontally or vertically (within a tolerance).', {
|
|
514
|
-
ids: z.array(z.string()).describe('Element IDs to check'),
|
|
515
|
-
axis: z.enum(['horizontal', 'vertical']).describe('Alignment axis'),
|
|
516
|
-
tolerance: z.number().optional().describe('Max deviation in scene units (default 10)'),
|
|
517
|
-
}, async ({ ids, axis, tolerance = 10 }) => {
|
|
518
|
-
const centers = [];
|
|
519
|
-
for (const id of ids) {
|
|
520
|
-
const el = _sharedState.scene.elements.find((e) => e.id === id);
|
|
521
|
-
if (!el)
|
|
522
|
-
return { content: [{ type: 'text', text: `Element "${id}" not found.` }] };
|
|
523
|
-
const b = getElementBounds(el);
|
|
524
|
-
centers.push({ id, cx: (b.minX + b.maxX) / 2, cy: (b.minY + b.maxY) / 2 });
|
|
525
|
-
}
|
|
526
|
-
const values = centers.map(c => axis === 'horizontal' ? c.cy : c.cx);
|
|
527
|
-
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
528
|
-
const maxDev = Math.max(...values.map(v => Math.abs(v - avg)));
|
|
529
|
-
const aligned = maxDev <= tolerance;
|
|
530
|
-
const details = centers.map(c => `${c.id}: (${Math.round(c.cx)}, ${Math.round(c.cy)})`).join(', ');
|
|
531
|
-
return { content: [{ type: 'text', text: `${aligned ? '✅ Aligned' : '❌ Not aligned'} (max deviation: ${Math.round(maxDev)}px, tolerance: ${tolerance}px). Centers: ${details}` }] };
|
|
532
|
-
});
|
|
533
|
-
server.tool('is_camera_centered', 'Check if the camera is centered on the scene content (horizontally, vertically, or both).', {
|
|
534
|
-
axis: z.enum(['horizontal', 'vertical', 'both']).describe('Which axis to check'),
|
|
535
|
-
time: z.number().min(0).default(0).describe('Time in ms to check at'),
|
|
536
|
-
tolerance: z.number().optional().describe('Max deviation (default 20)'),
|
|
537
|
-
}, async ({ axis, time, tolerance = 20 }) => {
|
|
538
|
-
// Scene content center
|
|
539
|
-
let sMinX = Infinity, sMinY = Infinity, sMaxX = -Infinity, sMaxY = -Infinity;
|
|
540
|
-
for (const el of _sharedState.scene.elements) {
|
|
541
|
-
const b = getElementBounds(el);
|
|
542
|
-
if (b.minX < sMinX)
|
|
543
|
-
sMinX = b.minX;
|
|
544
|
-
if (b.minY < sMinY)
|
|
545
|
-
sMinY = b.minY;
|
|
546
|
-
if (b.maxX > sMaxX)
|
|
547
|
-
sMaxX = b.maxX;
|
|
548
|
-
if (b.maxY > sMaxY)
|
|
549
|
-
sMaxY = b.maxY;
|
|
550
|
-
}
|
|
551
|
-
const sceneCX = (sMinX + sMaxX) / 2;
|
|
552
|
-
const sceneCY = (sMinY + sMaxY) / 2;
|
|
553
|
-
const cam = getCameraRectAt(time);
|
|
554
|
-
const dxOk = Math.abs(cam.cx - sceneCX) <= tolerance;
|
|
555
|
-
const dyOk = Math.abs(cam.cy - sceneCY) <= tolerance;
|
|
556
|
-
const ok = axis === 'horizontal' ? dxOk : axis === 'vertical' ? dyOk : dxOk && dyOk;
|
|
557
|
-
return { content: [{ type: 'text', text: `${ok ? '✅ Centered' : '❌ Not centered'} (scene center: ${Math.round(sceneCX)},${Math.round(sceneCY)}, camera center: ${Math.round(cam.cx)},${Math.round(cam.cy)}, offsets: dx=${Math.round(cam.cx - sceneCX)} dy=${Math.round(cam.cy - sceneCY)})` }] };
|
|
558
|
-
});
|
|
559
|
-
server.tool('items_visible_in_camera', 'Check what percentage of items are visible in the camera frame at a given time.', {
|
|
560
|
-
time: z.number().min(0).default(0).describe('Time in ms'),
|
|
561
|
-
}, async ({ time }) => {
|
|
562
|
-
const cam = getCameraRectAt(time);
|
|
563
|
-
const elements = _sharedState.scene.elements.filter((e) => !e.isDeleted && e.id !== '__camera_frame__');
|
|
564
|
-
let visible = 0;
|
|
565
|
-
const details = [];
|
|
566
|
-
for (const el of elements) {
|
|
567
|
-
const b = getAnimatedBoundsAt(el.id, time);
|
|
568
|
-
if (!b)
|
|
569
|
-
continue;
|
|
570
|
-
// Check opacity
|
|
571
|
-
const opTrack = _sharedState.timeline.tracks.find((t) => t.targetId === el.id && t.property === 'opacity');
|
|
572
|
-
const opacity = opTrack ? interpolateTrackAt(opTrack, time) : 1;
|
|
573
|
-
// Check if bounds overlap camera
|
|
574
|
-
const inView = b.maxX > cam.left && b.minX < cam.right && b.maxY > cam.top && b.minY < cam.bottom;
|
|
575
|
-
const isVisible = inView && opacity > 0.01;
|
|
576
|
-
if (isVisible)
|
|
577
|
-
visible++;
|
|
578
|
-
details.push(`${el.id}: ${isVisible ? '✅' : '❌'} (opacity=${(opacity * 100).toFixed(0)}%, inView=${inView})`);
|
|
579
|
-
}
|
|
580
|
-
const pct = elements.length > 0 ? Math.round(visible / elements.length * 100) : 0;
|
|
581
|
-
return { content: [{ type: 'text', text: `${visible}/${elements.length} items visible (${pct}%) at ${time}ms.\n${details.join('\n')}` }] };
|
|
582
|
-
});
|
|
583
|
-
server.tool('animations_of_item', 'Returns a timeline description of all animations an item goes through.', {
|
|
584
|
-
targetId: z.string().describe('Element ID'),
|
|
585
|
-
}, async ({ targetId }) => {
|
|
586
|
-
const tracks = _sharedState.timeline.tracks.filter((t) => t.targetId === targetId);
|
|
587
|
-
if (tracks.length === 0) {
|
|
588
|
-
return { content: [{ type: 'text', text: `No animations for "${targetId}".` }] };
|
|
589
|
-
}
|
|
590
|
-
const lines = [`Animations for "${targetId}":`];
|
|
591
|
-
for (const track of tracks) {
|
|
592
|
-
if (track.keyframes.length === 0)
|
|
593
|
-
continue;
|
|
594
|
-
lines.push(` ${track.property}:`);
|
|
595
|
-
for (let i = 0; i < track.keyframes.length; i++) {
|
|
596
|
-
const kf = track.keyframes[i];
|
|
597
|
-
const next = track.keyframes[i + 1];
|
|
598
|
-
if (next) {
|
|
599
|
-
const fromLabel = track.property === 'opacity' ? `${(kf.value * 100).toFixed(0)}%` : String(kf.value);
|
|
600
|
-
const toLabel = track.property === 'opacity' ? `${(next.value * 100).toFixed(0)}%` : String(next.value);
|
|
601
|
-
const direction = next.value > kf.value ? '↑' : next.value < kf.value ? '↓' : '→';
|
|
602
|
-
lines.push(` ${kf.time}ms ${fromLabel} ${direction} ${toLabel} ${next.time}ms (${kf.easing})`);
|
|
603
|
-
}
|
|
604
|
-
else {
|
|
605
|
-
const label = track.property === 'opacity' ? `${(kf.value * 100).toFixed(0)}%` : String(kf.value);
|
|
606
|
-
lines.push(` ${kf.time}ms ${label} (hold)`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
611
|
-
});
|
|
612
|
-
// ── BATCH DELETE TOOL ──────────────────────────────────────────
|
|
613
|
-
mutatingTool('delete_items', 'Delete specific elements and all their animation tracks. Batch operation.', {
|
|
614
|
-
ids: z.array(z.string()).describe('Element IDs to delete'),
|
|
615
|
-
}, async ({ ids }) => {
|
|
616
|
-
const idSet = new Set(ids);
|
|
617
|
-
const beforeEl = _sharedState.scene.elements.length;
|
|
618
|
-
const beforeTr = _sharedState.timeline.tracks.length;
|
|
619
|
-
_sharedState.scene.elements = _sharedState.scene.elements.filter((el) => !idSet.has(el.id));
|
|
620
|
-
_sharedState.timeline.tracks = _sharedState.timeline.tracks.filter((t) => !idSet.has(t.targetId));
|
|
621
|
-
const removedEl = beforeEl - _sharedState.scene.elements.length;
|
|
622
|
-
const removedTr = beforeTr - _sharedState.timeline.tracks.length;
|
|
623
|
-
return { content: [{ type: 'text', text: `Deleted ${removedEl} elements and ${removedTr} animation tracks.` }] };
|
|
624
|
-
});
|
|
625
|
-
// ── CHECKPOINT TOOLS ────────────────────────────────────────────
|
|
626
|
-
mutatingTool('save_checkpoint', 'Save current scene + animation state to a checkpoint.', { id: z.string().optional().describe('Checkpoint ID (auto-generated if omitted)') }, async ({ id }) => {
|
|
627
|
-
const checkpointId = id ?? nanoid(12);
|
|
628
|
-
await store.save(checkpointId, _sharedState);
|
|
629
|
-
return { content: [{ type: 'text', text: `Saved checkpoint: ${checkpointId}` }] };
|
|
630
|
-
});
|
|
631
|
-
mutatingTool('load_checkpoint', 'Load scene + animation state from a checkpoint.', { id: z.string().describe('Checkpoint ID') }, async ({ id }) => {
|
|
632
|
-
const loaded = await store.load(id);
|
|
633
|
-
if (!loaded)
|
|
634
|
-
return { content: [{ type: 'text', text: `Checkpoint "${id}" not found.` }] };
|
|
635
|
-
updateState(loaded);
|
|
636
|
-
return { content: [{ type: 'text', text: `Loaded checkpoint "${id}": ${_sharedState.scene.elements.length} elements, ${_sharedState.timeline.tracks.length} tracks.` }] };
|
|
637
|
-
});
|
|
638
|
-
server.tool('list_checkpoints', 'List all saved checkpoints.', {}, async () => {
|
|
639
|
-
const ids = await store.list();
|
|
640
|
-
return { content: [{ type: 'text', text: ids.length > 0 ? `Checkpoints:\n${ids.join('\n')}` : 'No checkpoints saved.' }] };
|
|
641
|
-
});
|
|
18
|
+
const server = new McpServer({ name: 'excalimate', version: PKG_VERSION });
|
|
19
|
+
const ctx = createStateContext(nanoid(8), server, onStateChange);
|
|
20
|
+
server.tool('read_me', 'Returns the Excalidraw element format reference, animation property docs, easing types, and usage examples. Call this FIRST before creating scenes or animations.', {}, async () => ({ content: [{ type: 'text', text: REFERENCE_TEXT }] }));
|
|
21
|
+
server.tool('get_examples', 'Returns few-shot examples showing how to create elements and animate them. Call this to learn common patterns.', {}, async () => ({ content: [{ type: 'text', text: EXAMPLES_TEXT }] }));
|
|
22
|
+
registerSceneTools(server, ctx, normalizeElements);
|
|
23
|
+
registerAnimationTools(server, ctx, getElementBounds);
|
|
24
|
+
registerQueryTools(server, ctx, geometry);
|
|
25
|
+
registerCheckpointTools(server, ctx, store);
|
|
26
|
+
registerShareTools(server, ctx);
|
|
642
27
|
return server;
|
|
643
28
|
}
|
|
644
|
-
// ── Reference text ────────────────────────────────────────────────
|
|
645
|
-
const REFERENCE_TEXT = `# Excalimate MCP Reference
|
|
646
|
-
|
|
647
|
-
## Excalidraw Element Format
|
|
648
|
-
|
|
649
|
-
Every element has these base properties:
|
|
650
|
-
\`\`\`json
|
|
651
|
-
{
|
|
652
|
-
"id": "unique-id",
|
|
653
|
-
"type": "rectangle|ellipse|diamond|arrow|line|text|freedraw|image",
|
|
654
|
-
"x": 100, "y": 200,
|
|
655
|
-
"width": 300, "height": 150,
|
|
656
|
-
"angle": 0,
|
|
657
|
-
"strokeColor": "#1e1e1e",
|
|
658
|
-
"backgroundColor": "transparent",
|
|
659
|
-
"fillStyle": "solid",
|
|
660
|
-
"strokeWidth": 2,
|
|
661
|
-
"roughness": 1,
|
|
662
|
-
"opacity": 100,
|
|
663
|
-
"groupIds": [],
|
|
664
|
-
"isDeleted": false
|
|
665
|
-
}
|
|
666
|
-
\`\`\`
|
|
667
|
-
|
|
668
|
-
### Text Elements
|
|
669
|
-
\`\`\`json
|
|
670
|
-
{
|
|
671
|
-
"type": "text",
|
|
672
|
-
"text": "Hello World",
|
|
673
|
-
"fontSize": 20,
|
|
674
|
-
"fontFamily": 5,
|
|
675
|
-
"textAlign": "center",
|
|
676
|
-
"verticalAlign": "middle"
|
|
677
|
-
}
|
|
678
|
-
\`\`\`
|
|
679
|
-
|
|
680
|
-
### Arrow/Line Elements
|
|
681
|
-
\`\`\`json
|
|
682
|
-
{
|
|
683
|
-
"type": "arrow",
|
|
684
|
-
"points": [[0, 0], [200, 100]],
|
|
685
|
-
"startArrowhead": null,
|
|
686
|
-
"endArrowhead": "arrow",
|
|
687
|
-
"startBinding": null,
|
|
688
|
-
"endBinding": null
|
|
689
|
-
}
|
|
690
|
-
\`\`\`
|
|
691
|
-
|
|
692
|
-
### Bound Text (Label on Shape)
|
|
693
|
-
Create a text element with \`containerId\` pointing to the shape:
|
|
694
|
-
\`\`\`json
|
|
695
|
-
{ "type": "text", "containerId": "shape-id", ... }
|
|
696
|
-
\`\`\`
|
|
697
|
-
And add to the shape: \`"boundElements": [{"id": "text-id", "type": "text"}]\`
|
|
698
|
-
|
|
699
|
-
## Color Palettes
|
|
700
|
-
|
|
701
|
-
**Stroke**: #1e1e1e, #e03131, #2f9e44, #1971c2, #f08c00, #6741d9, #0c8599, #e8590c
|
|
702
|
-
**Background**: transparent, #ffc9c9, #b2f2bb, #a5d8ff, #ffec99, #d0bfff, #99e9f2, #ffd8a8
|
|
703
|
-
|
|
704
|
-
## Animatable Properties
|
|
705
|
-
|
|
706
|
-
| Property | Range | Description |
|
|
707
|
-
|----------|-------|-------------|
|
|
708
|
-
| opacity | 0–1 | Element visibility (0=hidden, 1=visible) |
|
|
709
|
-
| translateX | px | Horizontal position offset |
|
|
710
|
-
| translateY | px | Vertical position offset |
|
|
711
|
-
| scaleX | 0.1+ | Horizontal scale (1=normal) |
|
|
712
|
-
| scaleY | 0.1+ | Vertical scale (1=normal) |
|
|
713
|
-
| rotation | degrees | Rotation angle |
|
|
714
|
-
| drawProgress | 0–1 | Stroke draw-on progress (for lines/arrows) |
|
|
715
|
-
|
|
716
|
-
## Easing Types
|
|
717
|
-
|
|
718
|
-
linear, easeIn, easeOut, easeInOut, easeInQuad, easeOutQuad, easeInOutQuad,
|
|
719
|
-
easeInCubic, easeOutCubic, easeInOutCubic, easeInBack, easeOutBack, easeInOutBack,
|
|
720
|
-
easeInElastic, easeOutElastic, easeInBounce, easeOutBounce, step
|
|
721
|
-
|
|
722
|
-
## Workflow
|
|
723
|
-
|
|
724
|
-
1. Call \`read_me\` (this tool) to get the reference
|
|
725
|
-
2. Call \`create_scene\` with Excalidraw elements JSON (or \`clear_scene\` to start fresh)
|
|
726
|
-
3. Call \`add_keyframe\` or \`add_keyframes_batch\` to animate elements
|
|
727
|
-
4. Use \`create_sequence\` for reveal animations
|
|
728
|
-
5. Call \`set_clip_range\` to set export bounds
|
|
729
|
-
6. Call \`save_checkpoint\` to persist
|
|
730
|
-
7. User opens the checkpoint in the Excalimate web app for preview/export
|
|
731
|
-
|
|
732
|
-
Use \`clear_scene\` to reset everything (elements + animations) or \`clear_animation\` to keep elements but remove all keyframes.
|
|
733
|
-
|
|
734
|
-
## Example: Fade-in Rectangle
|
|
735
|
-
|
|
736
|
-
\`\`\`
|
|
737
|
-
1. create_scene: [{"id":"rect1","type":"rectangle","x":100,"y":100,"width":200,"height":100,...}]
|
|
738
|
-
2. add_keyframe: {targetId:"rect1", property:"opacity", time:0, value:0}
|
|
739
|
-
3. add_keyframe: {targetId:"rect1", property:"opacity", time:1000, value:1, easing:"easeOut"}
|
|
740
|
-
\`\`\`
|
|
741
|
-
`;
|
|
742
|
-
const EXAMPLES_TEXT = `# Excalimate — Few-Shot Examples
|
|
743
|
-
|
|
744
|
-
## Example 1: Single Rectangle
|
|
745
|
-
\`\`\`
|
|
746
|
-
create_scene({ elements: '[{"id":"box1","type":"rectangle","x":200,"y":150,"width":250,"height":120,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid"}]' })
|
|
747
|
-
\`\`\`
|
|
748
|
-
|
|
749
|
-
## Example 2: Rectangle with Bound Text Label
|
|
750
|
-
\`\`\`
|
|
751
|
-
create_scene({ elements: '[{"id":"server","type":"rectangle","x":100,"y":100,"width":200,"height":80,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid","boundElements":[{"id":"server-label","type":"text"}]},{"id":"server-label","type":"text","x":140,"y":125,"width":120,"height":30,"text":"API Server","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"server"}]' })
|
|
752
|
-
\`\`\`
|
|
753
|
-
|
|
754
|
-
## Example 3: Two Shapes Connected by Arrow
|
|
755
|
-
\`\`\`
|
|
756
|
-
create_scene({ elements: '[{"id":"A","type":"rectangle","x":100,"y":200,"width":150,"height":80,"strokeColor":"#1e1e1e","backgroundColor":"#b2f2bb","fillStyle":"solid"},{"id":"B","type":"rectangle","x":500,"y":200,"width":150,"height":80,"strokeColor":"#1e1e1e","backgroundColor":"#a5d8ff","fillStyle":"solid"},{"id":"arrow1","type":"arrow","x":250,"y":240,"width":250,"height":0,"points":[[0,0],[250,0]],"endArrowhead":"arrow","startBinding":{"elementId":"A","focus":0,"gap":1},"endBinding":{"elementId":"B","focus":0,"gap":1}}]' })
|
|
757
|
-
\`\`\`
|
|
758
|
-
|
|
759
|
-
## Example 4: Ellipse and Diamond
|
|
760
|
-
\`\`\`
|
|
761
|
-
add_elements({ elements: '[{"id":"circle1","type":"ellipse","x":300,"y":100,"width":120,"height":120,"strokeColor":"#e03131","backgroundColor":"#ffc9c9","fillStyle":"solid"},{"id":"diamond1","type":"diamond","x":500,"y":90,"width":140,"height":140,"strokeColor":"#6741d9","backgroundColor":"#d0bfff","fillStyle":"solid"}]' })
|
|
762
|
-
\`\`\`
|
|
763
|
-
|
|
764
|
-
## Example 5: Multi-Point Line
|
|
765
|
-
\`\`\`
|
|
766
|
-
add_elements({ elements: '[{"id":"line1","type":"line","x":100,"y":300,"width":400,"height":80,"points":[[0,0],[200,-80],[400,0]],"strokeColor":"#e03131","strokeWidth":3}]' })
|
|
767
|
-
\`\`\`
|
|
768
|
-
|
|
769
|
-
## Example 6: Standalone Text
|
|
770
|
-
\`\`\`
|
|
771
|
-
add_elements({ elements: '[{"id":"title","type":"text","x":200,"y":50,"width":300,"height":50,"text":"Architecture Overview","fontSize":36,"fontFamily":5,"textAlign":"center","strokeColor":"#1e1e1e"}]' })
|
|
772
|
-
\`\`\`
|
|
773
|
-
|
|
774
|
-
---
|
|
775
|
-
|
|
776
|
-
# Animation Examples
|
|
777
|
-
|
|
778
|
-
## Example 7: Fade In
|
|
779
|
-
\`\`\`
|
|
780
|
-
add_keyframe({ targetId: "box1", property: "opacity", time: 0, value: 0 })
|
|
781
|
-
add_keyframe({ targetId: "box1", property: "opacity", time: 800, value: 1, easing: "easeOut" })
|
|
782
|
-
\`\`\`
|
|
783
|
-
|
|
784
|
-
## Example 8: Slide In from Left
|
|
785
|
-
\`\`\`
|
|
786
|
-
add_keyframes_batch({ keyframes: '[{"targetId":"box1","property":"translateX","time":0,"value":-300},{"targetId":"box1","property":"translateX","time":1000,"value":0,"easing":"easeOutCubic"},{"targetId":"box1","property":"opacity","time":0,"value":0},{"targetId":"box1","property":"opacity","time":500,"value":1,"easing":"easeOut"}]' })
|
|
787
|
-
\`\`\`
|
|
788
|
-
|
|
789
|
-
## Example 9: Pop In from Center (Scale Up with Bounce)
|
|
790
|
-
\`\`\`
|
|
791
|
-
add_keyframes_batch({ keyframes: '[{"targetId":"box1","property":"scaleX","time":0,"value":0.3,"scaleOrigin":"center"},{"targetId":"box1","property":"scaleY","time":0,"value":0.3,"scaleOrigin":"center"},{"targetId":"box1","property":"scaleX","time":600,"value":1,"easing":"easeOutBack","scaleOrigin":"center"},{"targetId":"box1","property":"scaleY","time":600,"value":1,"easing":"easeOutBack","scaleOrigin":"center"},{"targetId":"box1","property":"opacity","time":0,"value":0},{"targetId":"box1","property":"opacity","time":300,"value":1}]' })
|
|
792
|
-
\`\`\`
|
|
793
|
-
|
|
794
|
-
## Example 9b: Scale from Bottom Edge
|
|
795
|
-
\`\`\`
|
|
796
|
-
add_scale_animation({ targetId: "box1", origin: "bottom", keyframes: '[{"time":0,"scaleX":1,"scaleY":0},{"time":800,"scaleX":1,"scaleY":1,"easing":"easeOutCubic"}]' })
|
|
797
|
-
\`\`\`
|
|
798
|
-
Scale origins: center, top-left, top-right, bottom-left, bottom-right, top, bottom, left, right.
|
|
799
|
-
Add "scaleOrigin" per scaleX/scaleY keyframe in add_keyframes_batch, or use add_scale_animation for a single element.
|
|
800
|
-
|
|
801
|
-
## Example 10: Draw In an Arrow (Stroke Animation)
|
|
802
|
-
\`\`\`
|
|
803
|
-
add_keyframe({ targetId: "arrow1", property: "drawProgress", time: 0, value: 0 })
|
|
804
|
-
add_keyframe({ targetId: "arrow1", property: "drawProgress", time: 1200, value: 1, easing: "easeInOut" })
|
|
805
|
-
\`\`\`
|
|
806
|
-
|
|
807
|
-
## Example 11: Sequential Reveal — A → Arrow → B (Most Common Pattern)
|
|
808
|
-
\`\`\`
|
|
809
|
-
add_keyframes_batch({ keyframes: '[
|
|
810
|
-
{"targetId":"A","property":"opacity","time":0,"value":0},
|
|
811
|
-
{"targetId":"A","property":"opacity","time":600,"value":1,"easing":"easeOut"},
|
|
812
|
-
{"targetId":"arrow1","property":"opacity","time":0,"value":0},
|
|
813
|
-
{"targetId":"arrow1","property":"opacity","time":600,"value":0},
|
|
814
|
-
{"targetId":"arrow1","property":"opacity","time":700,"value":1},
|
|
815
|
-
{"targetId":"arrow1","property":"drawProgress","time":600,"value":0},
|
|
816
|
-
{"targetId":"arrow1","property":"drawProgress","time":1800,"value":1,"easing":"easeInOut"},
|
|
817
|
-
{"targetId":"B","property":"opacity","time":0,"value":0},
|
|
818
|
-
{"targetId":"B","property":"opacity","time":1800,"value":0},
|
|
819
|
-
{"targetId":"B","property":"opacity","time":2400,"value":1,"easing":"easeOut"}
|
|
820
|
-
]' })
|
|
821
|
-
\`\`\`
|
|
822
|
-
|
|
823
|
-
## Example 12: Bidirectional Flow — A ↔ B
|
|
824
|
-
\`\`\`
|
|
825
|
-
add_keyframes_batch({ keyframes: '[
|
|
826
|
-
{"targetId":"A","property":"opacity","time":0,"value":0},
|
|
827
|
-
{"targetId":"A","property":"opacity","time":500,"value":1,"easing":"easeOut"},
|
|
828
|
-
{"targetId":"arrowAB","property":"opacity","time":0,"value":0},
|
|
829
|
-
{"targetId":"arrowAB","property":"opacity","time":500,"value":1},
|
|
830
|
-
{"targetId":"arrowAB","property":"drawProgress","time":500,"value":0},
|
|
831
|
-
{"targetId":"arrowAB","property":"drawProgress","time":1500,"value":1,"easing":"easeInOut"},
|
|
832
|
-
{"targetId":"B","property":"opacity","time":0,"value":0},
|
|
833
|
-
{"targetId":"B","property":"opacity","time":1500,"value":0},
|
|
834
|
-
{"targetId":"B","property":"opacity","time":2000,"value":1,"easing":"easeOut"},
|
|
835
|
-
{"targetId":"arrowBA","property":"opacity","time":0,"value":0},
|
|
836
|
-
{"targetId":"arrowBA","property":"opacity","time":2000,"value":1},
|
|
837
|
-
{"targetId":"arrowBA","property":"drawProgress","time":2000,"value":0},
|
|
838
|
-
{"targetId":"arrowBA","property":"drawProgress","time":3000,"value":1,"easing":"easeInOut"}
|
|
839
|
-
]' })
|
|
840
|
-
\`\`\`
|
|
841
|
-
|
|
842
|
-
## Example 13: Staggered Reveal via create_sequence
|
|
843
|
-
\`\`\`
|
|
844
|
-
create_sequence({ elementIds: ["title","box1","arrow1","box2","arrow2","box3"], property: "opacity", startTime: 0, delay: 400, duration: 600 })
|
|
845
|
-
\`\`\`
|
|
846
|
-
Result: title at 0ms, box1 at 400ms, arrow1 at 800ms, box2 at 1200ms, arrow2 at 1600ms, box3 at 2000ms.
|
|
847
|
-
|
|
848
|
-
## Example 14: Camera Pan
|
|
849
|
-
\`\`\`
|
|
850
|
-
set_camera_frame({ x: 300, y: 200, width: 800, aspectRatio: "16:9" })
|
|
851
|
-
add_camera_keyframe({ property: "translateX", time: 0, value: -200 })
|
|
852
|
-
add_camera_keyframe({ property: "translateX", time: 3000, value: 200, easing: "easeInOut" })
|
|
853
|
-
\`\`\`
|
|
854
|
-
|
|
855
|
-
## Example 15: Camera Zoom In
|
|
856
|
-
\`\`\`
|
|
857
|
-
add_camera_keyframe({ property: "scaleX", time: 0, value: 2 })
|
|
858
|
-
add_camera_keyframe({ property: "scaleY", time: 0, value: 2 })
|
|
859
|
-
add_camera_keyframe({ property: "scaleX", time: 2000, value: 1, easing: "easeInOutCubic" })
|
|
860
|
-
add_camera_keyframe({ property: "scaleY", time: 2000, value: 1, easing: "easeInOutCubic" })
|
|
861
|
-
\`\`\`
|
|
862
|
-
|
|
863
|
-
## Example 16: Clip Range + Save
|
|
864
|
-
\`\`\`
|
|
865
|
-
set_clip_range({ start: 0, end: 5000 })
|
|
866
|
-
save_checkpoint({ id: "my-animation" })
|
|
867
|
-
\`\`\`
|
|
868
|
-
|
|
869
|
-
---
|
|
870
|
-
|
|
871
|
-
# Tips
|
|
872
|
-
|
|
873
|
-
1. Always call create_scene first (or clear_scene to start fresh), then animate.
|
|
874
|
-
2. Use add_keyframes_batch for efficiency — one call for many keyframes.
|
|
875
|
-
3. Use create_sequence for simple staggered reveals.
|
|
876
|
-
4. Bound text inherits container animation — animating arrow opacity also hides its label.
|
|
877
|
-
5. drawProgress only works on arrows and lines.
|
|
878
|
-
6. easeOutBack gives a nice bounce for pop-in effects.
|
|
879
|
-
7. easeInOutCubic is the best general-purpose easing.
|
|
880
|
-
8. Set elements to opacity 0 at time 0 if they should appear later.
|
|
881
|
-
9. Set clip range before saving — it defines what gets exported.
|
|
882
|
-
10. Camera scale > 1 = zoomed out, < 1 = zoomed in.
|
|
883
|
-
11. Use delete_items to remove elements AND their animation tracks in one call.
|
|
884
|
-
12. Verify your work with animations_of_item, items_visible_in_camera, are_items_in_line, is_camera_centered.
|
|
885
|
-
|
|
886
|
-
## Example 17: Verify Animation
|
|
887
|
-
\`\`\`
|
|
888
|
-
animations_of_item({ targetId: "box1" })
|
|
889
|
-
// Returns:
|
|
890
|
-
// opacity:
|
|
891
|
-
// 0ms 0% ↑ 100% 600ms (easeOut)
|
|
892
|
-
|
|
893
|
-
items_visible_in_camera({ time: 1000 })
|
|
894
|
-
// Returns: 5/8 items visible (62%) at 1000ms
|
|
895
|
-
|
|
896
|
-
are_items_in_line({ ids: ["box1","box2","box3"], axis: "horizontal" })
|
|
897
|
-
// Returns: ✅ Aligned (max deviation: 3px)
|
|
898
|
-
|
|
899
|
-
is_camera_centered({ axis: "both", time: 0 })
|
|
900
|
-
// Returns: ✅ Centered (offsets: dx=5 dy=2)
|
|
901
|
-
\`\`\`
|
|
902
|
-
|
|
903
|
-
## Example 18: Delete and Rebuild
|
|
904
|
-
\`\`\`
|
|
905
|
-
delete_items({ ids: ["old_box", "old_arrow"] })
|
|
906
|
-
// Removes elements + all their animation tracks
|
|
907
|
-
|
|
908
|
-
clear_scene()
|
|
909
|
-
// Nuclear option: removes everything
|
|
910
|
-
\`\`\`
|
|
911
|
-
`;
|
|
912
29
|
//# sourceMappingURL=server.js.map
|