@glissade/cli 0.27.1-pre.0 → 0.28.0-pre.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.
Files changed (3) hide show
  1. package/dist/cli.js +11 -1
  2. package/dist/mcp.js +330 -0
  3. 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,330 @@
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
+ scene;
35
+ codeTimeline;
36
+ sidecar = emptySidecar();
37
+ undoStack = [];
38
+ constructor(mod) {
39
+ this.scene = mod.createScene();
40
+ this.codeTimeline = mod.timeline;
41
+ }
42
+ static async load(modulePath) {
43
+ return new McpSession(await loadSceneModule(modulePath));
44
+ }
45
+ /** Seed a first edit on a code-only track from the code timeline's baseline (§6.2). */
46
+ baseline = (timelineId, target) => {
47
+ if (timelineId !== "main") return null;
48
+ const tr = this.codeTimeline.tracks.find((t) => t.target === target);
49
+ return tr ? {
50
+ type: tr.type,
51
+ keys: tr.keys
52
+ } : null;
53
+ };
54
+ /** The API manifest — the agent reads which props are animatable (per node type). */
55
+ describe() {
56
+ return describe();
57
+ }
58
+ /** The concrete animatable `<nodeId>/<prop>` targets in THIS scene (id-substituted). */
59
+ listTargets() {
60
+ const manifest = describe();
61
+ const out = [];
62
+ for (const [id, node] of this.scene.nodes) {
63
+ const desc = manifest.nodes[node.describeType];
64
+ if (!desc) continue;
65
+ for (const [prop, p] of Object.entries(desc.props)) if (p.animatable && p.target) out.push({
66
+ target: p.target.replace("<id>", id),
67
+ nodeId: id,
68
+ nodeType: node.describeType,
69
+ prop,
70
+ type: p.type
71
+ });
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * True if `target` (`<nodeId>/<prop.path>`) is a real animatable target of THIS
77
+ * scene — the node exists and registered that prop. This is the describe()-backed
78
+ * write-boundary check (resolveTarget reflects the same registrations describe()
79
+ * enumerates, PLUS sub-components like `position.x`). Fail-loud on a bad target.
80
+ */
81
+ isValidTarget(target) {
82
+ const slash = target.indexOf("/");
83
+ if (slash <= 0) return false;
84
+ const node = this.scene.nodes.get(target.slice(0, slash));
85
+ return node !== void 0 && node.resolveTarget(target.slice(slash + 1)) !== void 0;
86
+ }
87
+ /** Apply a validated, reversible patch batch. Records the inverse; doc is untouched on error. */
88
+ applyPatch(patches) {
89
+ for (const p of patches) if ("target" in p && !this.isValidTarget(p.target)) return {
90
+ ok: false,
91
+ error: `'${p.target}' is not an animatable target of this scene (unknown node or non-animatable prop) — call list_targets`
92
+ };
93
+ const r = applyPatches(this.sidecar, patches, this.baseline);
94
+ if (r.ok) {
95
+ this.sidecar = r.doc;
96
+ this.undoStack.push(r.inverse);
97
+ }
98
+ return r;
99
+ }
100
+ /** Undo the last applyPatch (apply its recorded inverse). */
101
+ undo() {
102
+ const inv = this.undoStack.pop();
103
+ if (!inv) return {
104
+ ok: false,
105
+ error: "nothing to undo"
106
+ };
107
+ const r = applyPatches(this.sidecar, inv, this.baseline);
108
+ if (r.ok) {
109
+ this.sidecar = r.doc;
110
+ return { ok: true };
111
+ }
112
+ this.undoStack.push(inv);
113
+ return {
114
+ ok: false,
115
+ error: r.error
116
+ };
117
+ }
118
+ /** The code timeline with the session's edits merged in (what render_frame evaluates). */
119
+ mergedTimeline() {
120
+ return mergeSidecar(this.codeTimeline, this.sidecar);
121
+ }
122
+ /** How many undoable edits are on the stack. */
123
+ editCount() {
124
+ return this.undoStack.length;
125
+ }
126
+ /** Render ONE frame of the current (patched) scene to a PNG — the agent's verifier. */
127
+ async renderFrame(t, outPath) {
128
+ if ([...this.scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
129
+ const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
130
+ await loadYogaLayoutEngine();
131
+ }
132
+ const dl = evaluate(this.scene, this.mergedTimeline(), t);
133
+ const backend = new SkiaBackend(this.scene.size.w, this.scene.size.h);
134
+ try {
135
+ this.scene.setTextMeasurer(backend);
136
+ backend.render(dl);
137
+ writeFileSync(outPath, backend.encodePng());
138
+ } finally {
139
+ backend.dispose();
140
+ }
141
+ return {
142
+ path: outPath,
143
+ width: this.scene.size.w,
144
+ height: this.scene.size.h
145
+ };
146
+ }
147
+ };
148
+ //#endregion
149
+ //#region src/mcp.ts
150
+ /**
151
+ * `gs mcp <scene>` (0.28): the AI-native WRITE layer as an MCP stdio server. It
152
+ * loads one scene and exposes the author→render→verify loop as tools an agent
153
+ * calls WITHOUT reading source — turning describe() from a read-only manifest into
154
+ * a full action space:
155
+ *
156
+ * describe → the API manifest (which props are animatable, per node type)
157
+ * list_targets → the concrete `<nodeId>/<prop>` animatable targets in THIS scene
158
+ * apply_patch → a VALIDATED, REVERSIBLE Timeline Patch batch (returns the inverse)
159
+ * undo → revert the last apply_patch
160
+ * render_frame → render one frame → a PNG the agent SEES inline (the verifier)
161
+ * get_timeline → the current (patched) merged timeline as JSON
162
+ *
163
+ * Lives in `cli` (Node-only) — NEVER on the ≤39 kB embed path. The tool logic is in
164
+ * `mcpSession.ts` (unit-tested); this file is the thin JSON-RPC/stdio wiring.
165
+ */
166
+ const TOOLS = [
167
+ {
168
+ name: "describe",
169
+ 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.",
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: {},
173
+ additionalProperties: false
174
+ }
175
+ },
176
+ {
177
+ name: "list_targets",
178
+ 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.",
179
+ inputSchema: {
180
+ type: "object",
181
+ properties: {},
182
+ additionalProperties: false
183
+ }
184
+ },
185
+ {
186
+ name: "apply_patch",
187
+ 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>', ... }.",
188
+ inputSchema: {
189
+ type: "object",
190
+ properties: { patches: {
191
+ type: "array",
192
+ items: { type: "object" },
193
+ description: "TimelinePatch[]"
194
+ } },
195
+ required: ["patches"],
196
+ additionalProperties: false
197
+ }
198
+ },
199
+ {
200
+ name: "undo",
201
+ description: "Revert the most recent apply_patch (apply its recorded inverse).",
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {},
205
+ additionalProperties: false
206
+ }
207
+ },
208
+ {
209
+ name: "render_frame",
210
+ 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.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: { t: {
214
+ type: "number",
215
+ description: "time in seconds",
216
+ minimum: 0
217
+ } },
218
+ required: ["t"],
219
+ additionalProperties: false
220
+ }
221
+ },
222
+ {
223
+ name: "get_timeline",
224
+ description: "The current merged timeline (code + your edits) as JSON — tracks, labels, duration.",
225
+ inputSchema: {
226
+ type: "object",
227
+ properties: {},
228
+ additionalProperties: false
229
+ }
230
+ }
231
+ ];
232
+ const text = (value) => ({ content: [{
233
+ type: "text",
234
+ text: JSON.stringify(value, null, 2)
235
+ }] });
236
+ /** Start the gs mcp stdio server for a scene module. Resolves when the transport closes. */
237
+ async function startMcpServer(modulePath) {
238
+ const session = await McpSession.load(modulePath);
239
+ const frameDir = mkdtempSync(join(tmpdir(), "gs-mcp-frames-"));
240
+ const server = new Server({
241
+ name: "glissade",
242
+ version: glissadeVersion()
243
+ }, { capabilities: { tools: {} } });
244
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map((t) => ({ ...t })) }));
245
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
246
+ const { name, arguments: args = {} } = req.params;
247
+ try {
248
+ switch (name) {
249
+ case "describe": return text(session.describe());
250
+ case "list_targets": return text(session.listTargets());
251
+ case "get_timeline": return text(session.mergedTimeline());
252
+ case "apply_patch": {
253
+ const patches = args.patches;
254
+ if (!Array.isArray(patches)) return {
255
+ content: [{
256
+ type: "text",
257
+ text: "error: `patches` must be an array of TimelinePatch ops"
258
+ }],
259
+ isError: true
260
+ };
261
+ const r = session.applyPatch(patches);
262
+ return r.ok ? text({
263
+ ok: true,
264
+ edits: session.editCount(),
265
+ inverse: r.inverse
266
+ }) : {
267
+ content: [{
268
+ type: "text",
269
+ text: `patch rejected: ${r.error}`
270
+ }],
271
+ isError: true
272
+ };
273
+ }
274
+ case "undo": {
275
+ const r = session.undo();
276
+ return r.ok ? text({
277
+ ok: true,
278
+ edits: session.editCount()
279
+ }) : {
280
+ content: [{
281
+ type: "text",
282
+ text: r.error ?? "undo failed"
283
+ }],
284
+ isError: true
285
+ };
286
+ }
287
+ case "render_frame": {
288
+ const t = Number(args.t);
289
+ if (!Number.isFinite(t) || t < 0) return {
290
+ content: [{
291
+ type: "text",
292
+ text: "error: `t` must be a number ≥ 0 (seconds)"
293
+ }],
294
+ isError: true
295
+ };
296
+ const out = join(frameDir, `frame-${Date.now()}.png`);
297
+ const info = await session.renderFrame(t, out);
298
+ const b64 = readFileSync(out).toString("base64");
299
+ return { content: [{
300
+ type: "text",
301
+ text: `rendered ${info.width}×${info.height} @ t=${t}s → ${out}`
302
+ }, {
303
+ type: "image",
304
+ data: b64,
305
+ mimeType: "image/png"
306
+ }] };
307
+ }
308
+ default: return {
309
+ content: [{
310
+ type: "text",
311
+ text: `unknown tool: ${name}`
312
+ }],
313
+ isError: true
314
+ };
315
+ }
316
+ } catch (err) {
317
+ return {
318
+ content: [{
319
+ type: "text",
320
+ text: `error: ${err instanceof Error ? err.message : String(err)}`
321
+ }],
322
+ isError: true
323
+ };
324
+ }
325
+ });
326
+ await server.connect(new StdioServerTransport());
327
+ await new Promise((resolve) => server.onclose = resolve);
328
+ }
329
+ //#endregion
330
+ export { startMcpServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.27.1-pre.0",
3
+ "version": "0.28.0-pre.0",
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.1-pre.0",
27
- "@glissade/core": "0.27.1-pre.0",
28
- "@glissade/interact": "0.27.1-pre.0",
29
- "@glissade/lottie": "0.27.1-pre.0",
30
- "@glissade/svg": "0.27.1-pre.0",
31
- "@glissade/narrate": "0.27.1-pre.0",
32
- "@glissade/player": "0.27.1-pre.0",
33
- "@glissade/scene": "0.27.1-pre.0",
34
- "@glissade/sfx": "0.27.1-pre.0"
27
+ "@glissade/backend-skia": "0.28.0-pre.0",
28
+ "@glissade/core": "0.28.0-pre.0",
29
+ "@glissade/interact": "0.28.0-pre.0",
30
+ "@glissade/lottie": "0.28.0-pre.0",
31
+ "@glissade/narrate": "0.28.0-pre.0",
32
+ "@glissade/player": "0.28.0-pre.0",
33
+ "@glissade/scene": "0.28.0-pre.0",
34
+ "@glissade/sfx": "0.28.0-pre.0",
35
+ "@glissade/svg": "0.28.0-pre.0"
35
36
  },
36
37
  "repository": {
37
38
  "type": "git",