@glissade/cli 0.27.1 → 0.28.0-pre.1
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/dist/cli.js +11 -1
- package/dist/mcp.js +340 -0
- package/package.json +11 -10
package/dist/cli.js
CHANGED
|
@@ -76,6 +76,7 @@ const USAGE = `usage:
|
|
|
76
76
|
gs measure-loudness <scene-module> [--profile <youtube|shorts|podcast|broadcast|ebu>] [--locale <code>]
|
|
77
77
|
gs fonts audit <scene-module> list registered families, formats, and missing-glyph runs (§3.6)
|
|
78
78
|
gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
|
|
79
|
+
gs mcp <scene-module> start an MCP stdio server for this scene: describe / list_targets / apply_patch / undo / render_frame (the AI-native write layer)
|
|
79
80
|
|
|
80
81
|
render options:
|
|
81
82
|
--out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
|
|
@@ -166,7 +167,7 @@ narration-lint options (lint the committed *.narration.timing.json + the real ca
|
|
|
166
167
|
`;
|
|
167
168
|
async function main() {
|
|
168
169
|
const [command, ...rest] = process.argv.slice(2);
|
|
169
|
-
if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache") {
|
|
170
|
+
if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp") {
|
|
170
171
|
console.error(USAGE);
|
|
171
172
|
process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
|
|
172
173
|
}
|
|
@@ -223,6 +224,15 @@ async function main() {
|
|
|
223
224
|
const { positional, flags } = parseArgs(rest);
|
|
224
225
|
const modulePath = positional[0];
|
|
225
226
|
if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json|asset.svg>" : "<scene-module>"}\n${USAGE}`);
|
|
227
|
+
if (command === "mcp") {
|
|
228
|
+
const { startMcpServer } = await import("./mcp.js");
|
|
229
|
+
try {
|
|
230
|
+
await startMcpServer(modulePath);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
226
236
|
if (command === "narrate") {
|
|
227
237
|
const { narrateCommand } = await import("./narrate.js");
|
|
228
238
|
try {
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { o as loadSceneModule } from "./render.js";
|
|
2
|
+
import { t as glissadeVersion } from "./version.js";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { evaluate } from "@glissade/scene";
|
|
7
|
+
import { SkiaBackend } from "@glissade/backend-skia";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { emptySidecar, mergeSidecar } from "@glissade/core/sidecar";
|
|
12
|
+
import { applyPatches } from "@glissade/core/studio-host";
|
|
13
|
+
import { describe } from "@glissade/scene/describe";
|
|
14
|
+
//#region src/mcpSession.ts
|
|
15
|
+
/**
|
|
16
|
+
* gs mcp session (0.28): the stateful core behind the AI-native WRITE layer. It
|
|
17
|
+
* holds one loaded scene + its editor sidecar, and exposes the author→render→verify
|
|
18
|
+
* loop as plain methods (mcp.ts wraps them as MCP tools; keeping the logic here
|
|
19
|
+
* makes it unit-testable without the stdio transport):
|
|
20
|
+
*
|
|
21
|
+
* - describe() — the API manifest: which props are animatable, per node TYPE.
|
|
22
|
+
* - listTargets() — the concrete `<nodeId>/<prop>` targets in THIS scene.
|
|
23
|
+
* - applyPatch() — a VALIDATED, REVERSIBLE Timeline Patch batch (records the inverse).
|
|
24
|
+
* - undo() — apply the last inverse.
|
|
25
|
+
* - renderFrame(t) — render one frame of the (patched) scene → PNG, the verifier.
|
|
26
|
+
*
|
|
27
|
+
* Every seam is a shipped primitive — describe() (0.18, can't drift), Timeline Patch
|
|
28
|
+
* (pure doc→doc, describe-validated, reversible), the sidecar merge, and a single
|
|
29
|
+
* deterministic Skia frame — so the whole loop stays pure. An agent authors, renders,
|
|
30
|
+
* and verifies a scene without ever reading source, and a bad patch can't emit a
|
|
31
|
+
* track for a non-animatable prop (fail-loud at the write boundary).
|
|
32
|
+
*/
|
|
33
|
+
var McpSession = class McpSession {
|
|
34
|
+
mod;
|
|
35
|
+
scene;
|
|
36
|
+
codeTimeline;
|
|
37
|
+
sidecar = emptySidecar();
|
|
38
|
+
undoStack = [];
|
|
39
|
+
constructor(mod) {
|
|
40
|
+
this.mod = mod;
|
|
41
|
+
this.scene = mod.createScene();
|
|
42
|
+
this.codeTimeline = mod.timeline;
|
|
43
|
+
}
|
|
44
|
+
static async load(modulePath) {
|
|
45
|
+
return new McpSession(await loadSceneModule(modulePath));
|
|
46
|
+
}
|
|
47
|
+
/** Seed a first edit on a code-only track from the code timeline's baseline (§6.2). */
|
|
48
|
+
baseline = (timelineId, target) => {
|
|
49
|
+
if (timelineId !== "main") return null;
|
|
50
|
+
const tr = this.codeTimeline.tracks.find((t) => t.target === target);
|
|
51
|
+
return tr ? {
|
|
52
|
+
type: tr.type,
|
|
53
|
+
keys: tr.keys
|
|
54
|
+
} : null;
|
|
55
|
+
};
|
|
56
|
+
/** The API manifest — the agent reads which props are animatable (per node type). */
|
|
57
|
+
describe() {
|
|
58
|
+
return describe();
|
|
59
|
+
}
|
|
60
|
+
/** The concrete animatable `<nodeId>/<prop>` targets in THIS scene (id-substituted). */
|
|
61
|
+
listTargets() {
|
|
62
|
+
const manifest = describe();
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const [id, node] of this.scene.nodes) {
|
|
65
|
+
const desc = manifest.nodes[node.describeType];
|
|
66
|
+
if (!desc) continue;
|
|
67
|
+
for (const [prop, p] of Object.entries(desc.props)) if (p.animatable && p.target) out.push({
|
|
68
|
+
target: p.target.replace("<id>", id),
|
|
69
|
+
nodeId: id,
|
|
70
|
+
nodeType: node.describeType,
|
|
71
|
+
prop,
|
|
72
|
+
type: p.type
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True if `target` (`<nodeId>/<prop.path>`) is a real animatable target of THIS
|
|
79
|
+
* scene — the node exists and registered that prop. This is the describe()-backed
|
|
80
|
+
* write-boundary check (resolveTarget reflects the same registrations describe()
|
|
81
|
+
* enumerates, PLUS sub-components like `position.x`). Fail-loud on a bad target.
|
|
82
|
+
*/
|
|
83
|
+
isValidTarget(target) {
|
|
84
|
+
const slash = target.indexOf("/");
|
|
85
|
+
if (slash <= 0) return false;
|
|
86
|
+
const node = this.scene.nodes.get(target.slice(0, slash));
|
|
87
|
+
return node !== void 0 && node.resolveTarget(target.slice(slash + 1)) !== void 0;
|
|
88
|
+
}
|
|
89
|
+
/** Apply a validated, reversible patch batch. Records the inverse; doc is untouched on error. */
|
|
90
|
+
applyPatch(patches) {
|
|
91
|
+
for (const p of patches) if ("target" in p && !this.isValidTarget(p.target)) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: `'${p.target}' is not an animatable target of this scene (unknown node or non-animatable prop) — call list_targets`
|
|
94
|
+
};
|
|
95
|
+
const r = applyPatches(this.sidecar, patches, this.baseline);
|
|
96
|
+
if (r.ok) {
|
|
97
|
+
this.sidecar = r.doc;
|
|
98
|
+
this.undoStack.push(r.inverse);
|
|
99
|
+
}
|
|
100
|
+
return r;
|
|
101
|
+
}
|
|
102
|
+
/** Undo the last applyPatch (apply its recorded inverse). */
|
|
103
|
+
undo() {
|
|
104
|
+
const inv = this.undoStack.pop();
|
|
105
|
+
if (!inv) return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: "nothing to undo"
|
|
108
|
+
};
|
|
109
|
+
const r = applyPatches(this.sidecar, inv, this.baseline);
|
|
110
|
+
if (r.ok) {
|
|
111
|
+
this.sidecar = r.doc;
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
this.undoStack.push(inv);
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: r.error
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The code timeline with the session's edits merged in (what render_frame evaluates). */
|
|
121
|
+
mergedTimeline() {
|
|
122
|
+
return mergeSidecar(this.codeTimeline, this.sidecar);
|
|
123
|
+
}
|
|
124
|
+
/** How many undoable edits are on the stack. */
|
|
125
|
+
editCount() {
|
|
126
|
+
return this.undoStack.length;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Render ONE frame of the current (patched) scene to a PNG — the agent's verifier.
|
|
130
|
+
* Builds a FRESH scene each call (like `gs render` does per run): the verifier is
|
|
131
|
+
* stateless, so a track that was added then undone — leaving the sidecar back at a
|
|
132
|
+
* prior/empty state — can't leave a stale binding on a reused scene instance
|
|
133
|
+
* (evaluate binds the current timeline's tracks but won't unbind a track absent
|
|
134
|
+
* from it). This keeps render_frame a pure function of (current merged timeline, t).
|
|
135
|
+
*/
|
|
136
|
+
async renderFrame(t, outPath) {
|
|
137
|
+
const scene = this.mod.createScene();
|
|
138
|
+
if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
|
|
139
|
+
const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
|
|
140
|
+
await loadYogaLayoutEngine();
|
|
141
|
+
}
|
|
142
|
+
const dl = evaluate(scene, this.mergedTimeline(), t);
|
|
143
|
+
const backend = new SkiaBackend(scene.size.w, scene.size.h);
|
|
144
|
+
try {
|
|
145
|
+
scene.setTextMeasurer(backend);
|
|
146
|
+
backend.render(dl);
|
|
147
|
+
writeFileSync(outPath, backend.encodePng());
|
|
148
|
+
} finally {
|
|
149
|
+
backend.dispose();
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
path: outPath,
|
|
153
|
+
width: scene.size.w,
|
|
154
|
+
height: scene.size.h
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/mcp.ts
|
|
160
|
+
/**
|
|
161
|
+
* `gs mcp <scene>` (0.28): the AI-native WRITE layer as an MCP stdio server. It
|
|
162
|
+
* loads one scene and exposes the author→render→verify loop as tools an agent
|
|
163
|
+
* calls WITHOUT reading source — turning describe() from a read-only manifest into
|
|
164
|
+
* a full action space:
|
|
165
|
+
*
|
|
166
|
+
* describe → the API manifest (which props are animatable, per node type)
|
|
167
|
+
* list_targets → the concrete `<nodeId>/<prop>` animatable targets in THIS scene
|
|
168
|
+
* apply_patch → a VALIDATED, REVERSIBLE Timeline Patch batch (returns the inverse)
|
|
169
|
+
* undo → revert the last apply_patch
|
|
170
|
+
* render_frame → render one frame → a PNG the agent SEES inline (the verifier)
|
|
171
|
+
* get_timeline → the current (patched) merged timeline as JSON
|
|
172
|
+
*
|
|
173
|
+
* Lives in `cli` (Node-only) — NEVER on the ≤39 kB embed path. The tool logic is in
|
|
174
|
+
* `mcpSession.ts` (unit-tested); this file is the thin JSON-RPC/stdio wiring.
|
|
175
|
+
*/
|
|
176
|
+
const TOOLS = [
|
|
177
|
+
{
|
|
178
|
+
name: "describe",
|
|
179
|
+
description: "The glissade API manifest: every node type with its props (animatable ones carry a track target template), value types, easings, builder methods and helpers. Read this to know what is animatable before patching.",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {},
|
|
183
|
+
additionalProperties: false
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "list_targets",
|
|
188
|
+
description: "The concrete animatable targets of the LOADED scene: for every node, its '<nodeId>/<prop>' track targets (id-substituted) + the value type each expects. Patch only these.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {},
|
|
192
|
+
additionalProperties: false
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "apply_patch",
|
|
197
|
+
description: "Apply a batch of Timeline Patch ops to the scene's edit sidecar — validated (a target that isn't animatable on this scene is rejected before it touches the doc) and reversible (undo restores it). Ops: setTrackKeys/removeTrack/addKey/removeKey/moveKey/setKeyValue/setKeyEase/setLabel/removeLabel. Each track op carries { op, timelineId:'main', target:'<id>/<prop>', ... }.",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: { patches: {
|
|
201
|
+
type: "array",
|
|
202
|
+
items: { type: "object" },
|
|
203
|
+
description: "TimelinePatch[]"
|
|
204
|
+
} },
|
|
205
|
+
required: ["patches"],
|
|
206
|
+
additionalProperties: false
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "undo",
|
|
211
|
+
description: "Revert the most recent apply_patch (apply its recorded inverse).",
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {},
|
|
215
|
+
additionalProperties: false
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "render_frame",
|
|
220
|
+
description: "Render ONE frame of the current (patched) scene at time t (seconds) to a PNG and return it inline as an image — the deterministic verifier. Call it after a patch to SEE the result.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: { t: {
|
|
224
|
+
type: "number",
|
|
225
|
+
description: "time in seconds",
|
|
226
|
+
minimum: 0
|
|
227
|
+
} },
|
|
228
|
+
required: ["t"],
|
|
229
|
+
additionalProperties: false
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "get_timeline",
|
|
234
|
+
description: "The current merged timeline (code + your edits) as JSON — tracks, labels, duration.",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {},
|
|
238
|
+
additionalProperties: false
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
];
|
|
242
|
+
const text = (value) => ({ content: [{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: JSON.stringify(value, null, 2)
|
|
245
|
+
}] });
|
|
246
|
+
/** Start the gs mcp stdio server for a scene module. Resolves when the transport closes. */
|
|
247
|
+
async function startMcpServer(modulePath) {
|
|
248
|
+
const session = await McpSession.load(modulePath);
|
|
249
|
+
const frameDir = mkdtempSync(join(tmpdir(), "gs-mcp-frames-"));
|
|
250
|
+
const server = new Server({
|
|
251
|
+
name: "glissade",
|
|
252
|
+
version: glissadeVersion()
|
|
253
|
+
}, { capabilities: { tools: {} } });
|
|
254
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map((t) => ({ ...t })) }));
|
|
255
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
256
|
+
const { name, arguments: args = {} } = req.params;
|
|
257
|
+
try {
|
|
258
|
+
switch (name) {
|
|
259
|
+
case "describe": return text(session.describe());
|
|
260
|
+
case "list_targets": return text(session.listTargets());
|
|
261
|
+
case "get_timeline": return text(session.mergedTimeline());
|
|
262
|
+
case "apply_patch": {
|
|
263
|
+
const patches = args.patches;
|
|
264
|
+
if (!Array.isArray(patches)) return {
|
|
265
|
+
content: [{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: "error: `patches` must be an array of TimelinePatch ops"
|
|
268
|
+
}],
|
|
269
|
+
isError: true
|
|
270
|
+
};
|
|
271
|
+
const r = session.applyPatch(patches);
|
|
272
|
+
return r.ok ? text({
|
|
273
|
+
ok: true,
|
|
274
|
+
edits: session.editCount(),
|
|
275
|
+
inverse: r.inverse
|
|
276
|
+
}) : {
|
|
277
|
+
content: [{
|
|
278
|
+
type: "text",
|
|
279
|
+
text: `patch rejected: ${r.error}`
|
|
280
|
+
}],
|
|
281
|
+
isError: true
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
case "undo": {
|
|
285
|
+
const r = session.undo();
|
|
286
|
+
return r.ok ? text({
|
|
287
|
+
ok: true,
|
|
288
|
+
edits: session.editCount()
|
|
289
|
+
}) : {
|
|
290
|
+
content: [{
|
|
291
|
+
type: "text",
|
|
292
|
+
text: r.error ?? "undo failed"
|
|
293
|
+
}],
|
|
294
|
+
isError: true
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
case "render_frame": {
|
|
298
|
+
const t = Number(args.t);
|
|
299
|
+
if (!Number.isFinite(t) || t < 0) return {
|
|
300
|
+
content: [{
|
|
301
|
+
type: "text",
|
|
302
|
+
text: "error: `t` must be a number ≥ 0 (seconds)"
|
|
303
|
+
}],
|
|
304
|
+
isError: true
|
|
305
|
+
};
|
|
306
|
+
const out = join(frameDir, `frame-${Date.now()}.png`);
|
|
307
|
+
const info = await session.renderFrame(t, out);
|
|
308
|
+
const b64 = readFileSync(out).toString("base64");
|
|
309
|
+
return { content: [{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: `rendered ${info.width}×${info.height} @ t=${t}s → ${out}`
|
|
312
|
+
}, {
|
|
313
|
+
type: "image",
|
|
314
|
+
data: b64,
|
|
315
|
+
mimeType: "image/png"
|
|
316
|
+
}] };
|
|
317
|
+
}
|
|
318
|
+
default: return {
|
|
319
|
+
content: [{
|
|
320
|
+
type: "text",
|
|
321
|
+
text: `unknown tool: ${name}`
|
|
322
|
+
}],
|
|
323
|
+
isError: true
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: `error: ${err instanceof Error ? err.message : String(err)}`
|
|
331
|
+
}],
|
|
332
|
+
isError: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
await server.connect(new StdioServerTransport());
|
|
337
|
+
await new Promise((resolve) => server.onclose = resolve);
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
export { startMcpServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0-pre.1",
|
|
4
4
|
"description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -20,18 +20,19 @@
|
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
23
24
|
"@napi-rs/canvas": "^0.1.65",
|
|
24
25
|
"esbuild": "0.28.0",
|
|
25
26
|
"jiti": "^2.4.2",
|
|
26
|
-
"@glissade/backend-skia": "0.
|
|
27
|
-
"@glissade/core": "0.
|
|
28
|
-
"@glissade/interact": "0.
|
|
29
|
-
"@glissade/lottie": "0.
|
|
30
|
-
"@glissade/
|
|
31
|
-
"@glissade/
|
|
32
|
-
"@glissade/
|
|
33
|
-
"@glissade/
|
|
34
|
-
"@glissade/
|
|
27
|
+
"@glissade/backend-skia": "0.28.0-pre.1",
|
|
28
|
+
"@glissade/core": "0.28.0-pre.1",
|
|
29
|
+
"@glissade/interact": "0.28.0-pre.1",
|
|
30
|
+
"@glissade/lottie": "0.28.0-pre.1",
|
|
31
|
+
"@glissade/narrate": "0.28.0-pre.1",
|
|
32
|
+
"@glissade/player": "0.28.0-pre.1",
|
|
33
|
+
"@glissade/scene": "0.28.0-pre.1",
|
|
34
|
+
"@glissade/sfx": "0.28.0-pre.1",
|
|
35
|
+
"@glissade/svg": "0.28.0-pre.1"
|
|
35
36
|
},
|
|
36
37
|
"repository": {
|
|
37
38
|
"type": "git",
|