@pinepaper.studio/mcp-server 1.6.3 → 1.6.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.
Files changed (54) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +51 -8
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  11. package/dist/browser/puppeteer-controller.js +22 -4
  12. package/dist/browser/puppeteer-controller.js.map +1 -1
  13. package/dist/tools/definitions.d.ts.map +1 -1
  14. package/dist/tools/definitions.js +560 -6
  15. package/dist/tools/definitions.js.map +1 -1
  16. package/dist/tools/handlers.d.ts.map +1 -1
  17. package/dist/tools/handlers.js +73 -2
  18. package/dist/tools/handlers.js.map +1 -1
  19. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  20. package/dist/tools/minimal-descriptions.js +21 -3
  21. package/dist/tools/minimal-descriptions.js.map +1 -1
  22. package/dist/tools/toolkits.d.ts.map +1 -1
  23. package/dist/tools/toolkits.js +25 -0
  24. package/dist/tools/toolkits.js.map +1 -1
  25. package/dist/types/code-generator.d.ts +51 -2
  26. package/dist/types/code-generator.d.ts.map +1 -1
  27. package/dist/types/code-generator.js +781 -40
  28. package/dist/types/code-generator.js.map +1 -1
  29. package/dist/types/schemas.d.ts +2656 -116
  30. package/dist/types/schemas.d.ts.map +1 -1
  31. package/dist/types/schemas.js +331 -6
  32. package/dist/types/schemas.js.map +1 -1
  33. package/manifest.json +65 -9
  34. package/package.json +13 -5
  35. package/dist/__tests__/fixtures/index.d.ts +0 -8
  36. package/dist/__tests__/fixtures/index.d.ts.map +0 -1
  37. package/dist/__tests__/fixtures/index.js +0 -8
  38. package/dist/__tests__/fixtures/index.js.map +0 -1
  39. package/dist/__tests__/fixtures/mock-animations.d.ts +0 -213
  40. package/dist/__tests__/fixtures/mock-animations.d.ts.map +0 -1
  41. package/dist/__tests__/fixtures/mock-animations.js +0 -96
  42. package/dist/__tests__/fixtures/mock-animations.js.map +0 -1
  43. package/dist/__tests__/fixtures/mock-generators.d.ts +0 -294
  44. package/dist/__tests__/fixtures/mock-generators.d.ts.map +0 -1
  45. package/dist/__tests__/fixtures/mock-generators.js +0 -175
  46. package/dist/__tests__/fixtures/mock-generators.js.map +0 -1
  47. package/dist/__tests__/fixtures/mock-items.d.ts +0 -157
  48. package/dist/__tests__/fixtures/mock-items.d.ts.map +0 -1
  49. package/dist/__tests__/fixtures/mock-items.js +0 -74
  50. package/dist/__tests__/fixtures/mock-items.js.map +0 -1
  51. package/dist/__tests__/fixtures/mock-relations.d.ts +0 -195
  52. package/dist/__tests__/fixtures/mock-relations.d.ts.map +0 -1
  53. package/dist/__tests__/fixtures/mock-relations.js +0 -121
  54. package/dist/__tests__/fixtures/mock-relations.js.map +0 -1
@@ -192,16 +192,28 @@ app.historyManager.saveState();
192
192
  function generateDeleteItemCode(itemId) {
193
193
  return `
194
194
  // Delete item ${itemId}
195
- const item = app.getItemById('${itemId}');
196
- if (!item) {
197
- throw new Error('Item not found: ${itemId}');
198
- }
199
- item.remove();
200
- app.itemRegistry.remove('${itemId}');
201
- app.historyManager.saveState();
202
-
203
- // Return success
204
- ({ success: true, itemId: '${itemId}' });
195
+ //
196
+ // Delegate to app.deleteItem rather than hand-rolling the removal. The old
197
+ // inline version called \`app.itemRegistry.remove(...)\`, which does not exist —
198
+ // the method is \`unregister\` — so the Paper item left the canvas while its
199
+ // REGISTRY ROW SURVIVED, pointing at a detached object. The scene then reported
200
+ // nodes that were not on screen, agents could still address them, and relations
201
+ // to them looked alive. It also skipped the identity-keyed trackers
202
+ // (animatedItems / keyframeItems / selectedItems), so a deleted item that was
203
+ // animating kept being ticked every frame.
204
+ //
205
+ // app.deleteItem does all of that, plus the redraw, the history snapshot and
206
+ // the collaboration mutation event, and it resolves any ref form.
207
+ (function () {
208
+ if (typeof app.deleteItem !== 'function') {
209
+ return { success: false, error: 'app.deleteItem unavailable — update FxTool' };
210
+ }
211
+ const ok = app.deleteItem('${itemId}');
212
+ if (!ok) {
213
+ return { success: false, error: 'Item not found: ${itemId}' };
214
+ }
215
+ return { success: true, itemId: '${itemId}' };
216
+ })();
205
217
  `.trim();
206
218
  }
207
219
  /**
@@ -247,7 +259,11 @@ const relations = app.getRelations('${itemId}', ${typeArg});
247
259
  // Format results
248
260
  const formatted = relations.map(r => ({
249
261
  sourceId: r.sourceId || '${itemId}',
250
- targetId: r.targetId,
262
+ // RelationRegistry.getAssociations returns { relation, target, params } —
263
+ // there is no \`targetId\` on it. Reading the wrong field made EVERY edge come
264
+ // back with an undefined destination, so the graph was unreadable through
265
+ // this tool even though it was wired correctly on the canvas.
266
+ targetId: r.target ?? r.targetId ?? null,
251
267
  relationType: r.relation || r.type,
252
268
  params: r.params || {}
253
269
  }));
@@ -265,7 +281,10 @@ const results = app.queryByRelationTarget('${itemId}', ${typeArg});
265
281
  const relations = results.map(r => ({
266
282
  sourceId: r.itemId,
267
283
  targetId: '${itemId}',
268
- relationType: '${relationType || 'unknown'}',
284
+ // queryByTarget returns the real relation name on each result. Hardcoding
285
+ // the filter string labelled every edge 'unknown' on an unfiltered query —
286
+ // which is exactly the query you make when exploring an unfamiliar graph.
287
+ relationType: r.relation || ${relationType ? `'${relationType}'` : "'unknown'"},
269
288
  params: r.params || {}
270
289
  }));
271
290
 
@@ -869,19 +888,38 @@ export class PinePaperCodeGenerator {
869
888
  /**
870
889
  * Generate code for importing SVG
871
890
  */
872
- generateImportSVG(svgString, url, position = { x: 400, y: 300 }, scale = 1.0) {
891
+ generateImportSVG(svgString, url, position = { x: 400, y: 300 }, scale = 1.0, source) {
892
+ // Figma is an OPTION on this tool rather than a tool of its own: it is the
893
+ // same import with a normalisation pass in front, and the tool count is
894
+ // already the thing the consolidation backlog objects to.
895
+ //
896
+ // The pass is not cosmetic. Figma's "Copy as SVG" carries a root
897
+ // fill="none" that INHERITS (everything renders invisible — the classic "I
898
+ // pasted my icon and got nothing"), frequently no viewBox, and global ids
899
+ // like `clip0` that make a second import silently adopt the first one's
900
+ // clipPath.
901
+ const importExpr = (varName) => source === 'figma'
902
+ ? `(function () {
903
+ if (typeof app.importFigmaSVG !== 'function') {
904
+ return { item: app.importSVG(${varName}), changes: ['figma normalisation unavailable — update PinePaper Studio'] };
905
+ }
906
+ const r = app.importFigmaSVG(${varName});
907
+ return { item: r.item || null, changes: r.changes || [] };
908
+ })()`
909
+ : `{ item: app.importSVG(${varName}), changes: [] }`;
873
910
  if (url) {
874
911
  return `
875
- // Import SVG from URL
912
+ // Import SVG from URL${source === 'figma' ? ' (Figma-normalised)' : ''}
876
913
  const response = await fetch('${url}');
877
914
  const svgText = await response.text();
878
- const imported = app.importSVG(svgText);
915
+ const _r = ${importExpr('svgText')};
916
+ const imported = _r.item;
879
917
  if (imported) {
880
918
  imported.position = new paper.Point(${position.x}, ${position.y});
881
919
  imported.scale(${scale});
882
920
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
883
921
  app.historyManager.saveState();
884
- ({ success: true, itemId, position: { x: ${position.x}, y: ${position.y} } });
922
+ ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
885
923
  } else {
886
924
  throw new Error('Failed to import SVG from URL');
887
925
  }
@@ -891,15 +929,16 @@ if (imported) {
891
929
  // Escape the SVG string for embedding in code
892
930
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
893
931
  return `
894
- // Import SVG string
932
+ // Import SVG string${source === 'figma' ? ' (Figma-normalised)' : ''}
895
933
  const svgString = \`${escapedSvg}\`;
896
- const imported = app.importSVG(svgString);
934
+ const _r = ${importExpr('svgString')};
935
+ const imported = _r.item;
897
936
  if (imported) {
898
937
  imported.position = new paper.Point(${position.x}, ${position.y});
899
938
  imported.scale(${scale});
900
939
  const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
901
940
  app.historyManager.saveState();
902
- ({ success: true, itemId, position: { x: ${position.x}, y: ${position.y} } });
941
+ ({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
903
942
  } else {
904
943
  throw new Error('Failed to import SVG');
905
944
  }
@@ -1354,7 +1393,10 @@ results;
1354
1393
  const styleStr = style ? JSON.stringify(style) : '{}';
1355
1394
  const widthVal = width || 120;
1356
1395
  const heightVal = height || 60;
1357
- const labelStr = label ? `'${label.replace(/'/g, "\\'")}'` : 'null';
1396
+ // JSON.stringify emits a complete escaped literal — hand-rolled quote
1397
+ // escaping missed backslashes (CodeQL js/incomplete-sanitization: an input
1398
+ // ending in \ escapes the closing quote; 'x\\'; evil()' breaks out).
1399
+ const labelStr = label ? JSON.stringify(label) : 'null';
1358
1400
  return `
1359
1401
  // Create diagram shape: ${shapeType}
1360
1402
  const shapeConfig = {
@@ -1505,7 +1547,7 @@ const shapes = app.diagramManager.getAvailableShapes(category);
1505
1547
  updates.push(`style: ${JSON.stringify(style)}`);
1506
1548
  }
1507
1549
  if (label !== undefined) {
1508
- updates.push(`label: '${label.replace(/'/g, "\\'")}'`);
1550
+ updates.push(`label: ${JSON.stringify(label)}`);
1509
1551
  }
1510
1552
  if (labelPosition !== undefined) {
1511
1553
  updates.push(`labelPosition: ${labelPosition}`);
@@ -1589,7 +1631,7 @@ throw new Error('Unknown diagram mode action: ${action}');
1589
1631
  generateAgentStartJob(input) {
1590
1632
  const validated = AgentStartJobInputSchema.parse(input);
1591
1633
  const { name, screenshotPolicy, canvasPreset, clearCanvas, includeOntology } = validated;
1592
- const nameStr = name ? `'${name.replace(/'/g, "\\'")}'` : 'null';
1634
+ const nameStr = name ? JSON.stringify(name) : 'null';
1593
1635
  const policyStr = screenshotPolicy || 'on_complete';
1594
1636
  const shouldClear = clearCanvas !== false;
1595
1637
  const wantOntology = includeOntology !== false;
@@ -2121,7 +2163,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2121
2163
  */
2122
2164
  generateAgentExport(input) {
2123
2165
  const validated = AgentExportInputSchema.parse(input);
2124
- const { platform, format, quality, framing, duration } = validated;
2166
+ const { platform, format, quality, framing, duration, estimateOnly } = validated;
2125
2167
  const qualityLevel = quality || 'standard';
2126
2168
  const videoDuration = duration ?? 5;
2127
2169
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -2156,6 +2198,33 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2156
2198
  const settings = ${JSON.stringify(qualitySettings)};
2157
2199
  const dimensions = ${JSON.stringify({ width: preset.width, height: preset.height })};
2158
2200
 
2201
+ // Preflight: same resolved settings as the real export, but render nothing.
2202
+ // Branching HERE rather than in a separate tool is deliberate — the estimate
2203
+ // has to see the same platform dimensions, fps and quality the export would
2204
+ // use, and a parallel tool would re-derive them and drift.
2205
+ if (${estimateOnly ? 'true' : 'false'}) {
2206
+ if (typeof app.estimateExportSize !== 'function') {
2207
+ return { success: false, estimateOnly: true, error: 'This PinePaper build has no estimateExportSize() — update the editor to preflight export size.' };
2208
+ }
2209
+ const est = await app.estimateExportSize({
2210
+ format,
2211
+ duration: ${videoDuration},
2212
+ fps: settings.fps,
2213
+ quality,
2214
+ width: dimensions.width,
2215
+ height: dimensions.height,
2216
+ });
2217
+ return {
2218
+ success: true,
2219
+ estimateOnly: true,
2220
+ platform, format, quality,
2221
+ duration: ${videoDuration},
2222
+ fps: settings.fps,
2223
+ dimensions: dimensions.width + 'x' + dimensions.height,
2224
+ estimate: est,
2225
+ };
2226
+ }
2227
+
2159
2228
  // Resolve camera-view framing dims when requested. Reads the first
2160
2229
  // camera_animates keyframe's zoom and divides the canvas dims by it
2161
2230
  // (mirrors FxTool ExportEngine.js:466-485). Camera animation still
@@ -3243,12 +3312,20 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3243
3312
  const relations = [];
3244
3313
  const decorative = [];
3245
3314
 
3246
- // Collect items from registry
3247
- if (app.itemRegistry) {
3248
- for (const [id, item] of app.itemRegistry.entries()) {
3315
+ // Collect items from registry.
3316
+ //
3317
+ // ItemRegistry exposes get/getAll — never \`entries()\`. Iterating a
3318
+ // non-existent method threw on EVERY call, so this tool has never once
3319
+ // returned a scene. getAll() yields registry ENTRIES
3320
+ // ({ itemId, item, type, properties }), not bare Paper items, so the
3321
+ // geometry has to be read off entry.item.
3322
+ if (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
3323
+ for (const entry of app.itemRegistry.getAll()) {
3324
+ const item = entry.item;
3325
+ if (!item) continue;
3249
3326
  const itemData = {
3250
- id: id,
3251
- type: item.data?.type || item.className?.toLowerCase() || 'unknown',
3327
+ id: entry.itemId,
3328
+ type: entry.type || item.data?.type || item.className?.toLowerCase() || 'unknown',
3252
3329
  position: item.position ? { x: item.position.x, y: item.position.y } : null,
3253
3330
  bounds: item.bounds ? {
3254
3331
  x: item.bounds.x,
@@ -3266,6 +3343,30 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3266
3343
  if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3267
3344
  if (item.rotation) itemData.properties.rotation = item.rotation;
3268
3345
  if (item.data?.content) itemData.properties.content = item.data.content;
3346
+ if (item.visible === false) itemData.properties.visible = false;
3347
+
3348
+ // THE SECOND TIMELINE. A video/audio item carries its own clip window
3349
+ // (media-time in/out) plus where that window sits on the project
3350
+ // timeline. Without it a consumer sees "there is a video here" and has
3351
+ // no idea which 5 seconds of it play, or when.
3352
+ const p = entry.properties || {};
3353
+ if (p.inPoint !== undefined || p.outPoint !== undefined || p.clipStartTime !== undefined) {
3354
+ itemData.clip = {
3355
+ clipStartTime: p.clipStartTime ?? 0, // project-timeline start, s
3356
+ inPoint: p.inPoint ?? 0, // media-time in, s
3357
+ outPoint: p.outPoint ?? null // media-time out, s
3358
+ };
3359
+ }
3360
+
3361
+ // When this item is on screen at all, merged across keyframe clip
3362
+ // window / loop / mask / relation windows / effects / media clip.
3363
+ // null end = open-ended. Static items report null.
3364
+ if (typeof app.activeWindowOf === 'function') {
3365
+ try {
3366
+ const w = app.activeWindowOf(entry.itemId);
3367
+ if (w) itemData.activeWindow = w;
3368
+ } catch (_) { /* non-fatal — an item without a window is just static */ }
3369
+ }
3269
3370
 
3270
3371
  if (item.data?.isDecorative) {
3271
3372
  decorative.push(itemData);
@@ -3275,14 +3376,19 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3275
3376
  }
3276
3377
  }
3277
3378
 
3278
- // Collect relations
3279
- if (app.getRelations) {
3280
- const activeRelations = app.getRelations();
3281
- for (const rel of activeRelations) {
3379
+ // Collect relations — the EDGE LIST, which with the items above is the
3380
+ // scene DAG.
3381
+ //
3382
+ // \`app.getRelations()\` takes an itemId; called bare it returns [], so the
3383
+ // old code emitted an empty edge list for every scene. exportForSave() is
3384
+ // the canonical whole-graph dump (the same one history persistence uses)
3385
+ // and returns { fromId, toId, relation, params }.
3386
+ if (app.relationRegistry && typeof app.relationRegistry.exportForSave === 'function') {
3387
+ for (const rel of app.relationRegistry.exportForSave()) {
3282
3388
  relations.push({
3283
- sourceId: rel.sourceId,
3284
- targetId: rel.targetId,
3285
- type: rel.type,
3389
+ sourceId: rel.fromId,
3390
+ targetId: rel.toId ?? null, // null = self-relation, not a missing edge
3391
+ type: rel.relation,
3286
3392
  params: rel.params
3287
3393
  });
3288
3394
  }
@@ -3698,9 +3804,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3698
3804
  }
3699
3805
  generateImportImage(input) {
3700
3806
  const { url, position, maxWidth, maxHeight, mask } = input;
3701
- const positionOpts = position ? `, position: { x: ${position.x}, y: ${position.y} }` : '';
3702
- const maxWidthOpt = maxWidth !== undefined ? `, maxWidth: ${maxWidth}` : '';
3703
- const maxHeightOpt = maxHeight !== undefined ? `, maxHeight: ${maxHeight}` : '';
3807
+ // Each fragment used to be written with a LEADING comma, as if it followed
3808
+ // an entry that no longer exists — so the first one opened the object with
3809
+ // `{, position: …}` and the emitted code was a syntax error. Passing any
3810
+ // option at all therefore failed 100% of the time, which is why imported
3811
+ // images could never be positioned, sized or masked. Join instead, so the
3812
+ // separators come from the number of options rather than from each one.
3813
+ const optParts = [
3814
+ position ? `position: { x: ${position.x}, y: ${position.y} }` : '',
3815
+ maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
3816
+ maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
3817
+ ].filter(Boolean);
3818
+ const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
3704
3819
  return `
3705
3820
  // Import image from URL
3706
3821
  (async function() {
@@ -3709,9 +3824,20 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3709
3824
  }
3710
3825
  try {
3711
3826
  const entry = await app.imageTools.uploadFromURL('${url}');
3712
- const opts = {${positionOpts}${maxWidthOpt}${maxHeightOpt} };
3827
+ const opts = ${optsLiteral};
3713
3828
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
3714
- ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const itemId = raster.data?.itemId || raster.name || raster.id;
3829
+ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
3830
+ // fell through to \`raster.id\` — a Paper.js NUMBER — and the handle the tool
3831
+ // returned could not be used as a relation endpoint or with modify/animate.
3832
+ // Register the raster if placeImage somehow left it unregistered, so the
3833
+ // caller always gets a usable id rather than a number that looks like one.
3834
+ let itemId = raster.data?.id;
3835
+ if (!itemId && typeof app.registerItem === 'function') {
3836
+ itemId = app.registerItem(raster, 'image', { source: 'mcp-import' });
3837
+ }
3838
+ if (!itemId) {
3839
+ return { error: 'Image placed but not registered — no usable item id. This is a bug; report the scene.' };
3840
+ }
3715
3841
  return {
3716
3842
  success: true,
3717
3843
  itemId: itemId,
@@ -3990,8 +4116,210 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3990
4116
  return `(function() { return { error: 'Unknown selection action: ${input.action}' }; })();`;
3991
4117
  }
3992
4118
  }
4119
+ /**
4120
+ * @private Guard + call one PinePaper facade, uniformly.
4121
+ *
4122
+ * Every one of the 1.6.4 tools wraps a facade that a given editor build may
4123
+ * not have yet — the server ships independently of the app. A missing method
4124
+ * has to come back as a NAMED error, not a TypeError inside runGenerated:
4125
+ * this repo has shipped three emitters that called methods which never
4126
+ * existed (`imageTools.applyFilter`, `itemRegistry.entries`,
4127
+ * `itemRegistry.remove`) and each reported success or an opaque throw.
4128
+ */
4129
+ _facadeCall(method, args, label) {
4130
+ return `
4131
+ // ${label}
4132
+ (async function() {
4133
+ if (typeof app.${method} !== 'function') {
4134
+ return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
4135
+ }
4136
+ const r = await app.${method}(${args});
4137
+ // Facades report { ok } — normalise to { success } so every tool result reads
4138
+ // the same way, while keeping the original payload intact.
4139
+ if (r && typeof r === 'object' && 'ok' in r) {
4140
+ return { success: r.ok !== false, ...r };
4141
+ }
4142
+ return { success: true, result: r };
4143
+ })();`.trim();
4144
+ }
4145
+ generateBrandKit(input) {
4146
+ const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
4147
+ const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
4148
+ return this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
4149
+ }
4150
+ generateComponent(input) {
4151
+ const ids = JSON.stringify(input.itemIds || []);
4152
+ switch (input.action) {
4153
+ case 'define':
4154
+ // defineComponent takes ITEMS, not ids — resolve through the registry
4155
+ // here so the tool can speak the id space every other tool speaks.
4156
+ return `
4157
+ // Component: define
4158
+ (function() {
4159
+ if (typeof app.defineComponent !== 'function') {
4160
+ return { success: false, error: 'app.defineComponent() unavailable — update PinePaper Studio.' };
4161
+ }
4162
+ const items = ${ids}.map((id) => app.itemRegistry.get(id)?.item).filter(Boolean);
4163
+ if (!items.length) return { success: false, error: 'no live items for the given ids' };
4164
+ const r = app.defineComponent(items, ${JSON.stringify({ name: input.name })});
4165
+ return { success: r.ok !== false, ...r };
4166
+ })();`.trim();
4167
+ case 'list':
4168
+ return this._facadeCall('listComponents', '', 'Component: list');
4169
+ case 'instantiate':
4170
+ return this._facadeCall('instantiateComponent', `${JSON.stringify(input.componentId || '')}, ${JSON.stringify({
4171
+ ...(input.position ? { position: input.position } : {}),
4172
+ ...(input.overrides ? { overrides: input.overrides } : {}),
4173
+ })}`, 'Component: instantiate');
4174
+ case 'set_override':
4175
+ return this._facadeCall('setComponentOverride', `${JSON.stringify(input.instanceId || '')}, ${JSON.stringify(input.componentKey || '')}, ${JSON.stringify(input.prop || '')}, ${JSON.stringify(input.value ?? null)}`, 'Component: set override');
4176
+ case 'sync':
4177
+ return this._facadeCall('syncComponent', JSON.stringify(input.componentId || ''), 'Component: sync');
4178
+ case 'update_from_instance':
4179
+ return this._facadeCall('updateComponentFromInstance', JSON.stringify(input.instanceId || ''), 'Component: update from instance');
4180
+ case 'detach':
4181
+ return this._facadeCall('detachComponentInstance', JSON.stringify(input.instanceId || ''), 'Component: detach');
4182
+ }
4183
+ }
4184
+ generateArtboard(input) {
4185
+ switch (input.action) {
4186
+ case 'list_presets':
4187
+ return this._facadeCall('listArtboardPresets', '', 'Artboard: list presets');
4188
+ case 'set': {
4189
+ const target = input.preset
4190
+ ? JSON.stringify(input.preset)
4191
+ : JSON.stringify({ width: input.width, height: input.height });
4192
+ return this._facadeCall('setArtboard', target, 'Artboard: set');
4193
+ }
4194
+ case 'set_constraints':
4195
+ return this._facadeCall('setItemConstraints', `${JSON.stringify(input.itemId || '')}, ${JSON.stringify({
4196
+ ...(input.horizontal ? { horizontal: input.horizontal } : {}),
4197
+ ...(input.vertical ? { vertical: input.vertical } : {}),
4198
+ })}`, 'Artboard: set constraints');
4199
+ }
4200
+ }
4201
+ generateComment(input) {
4202
+ switch (input.action) {
4203
+ case 'add':
4204
+ return this._facadeCall('addComment', JSON.stringify({
4205
+ text: input.text,
4206
+ ...(input.author ? { author: input.author } : {}),
4207
+ ...(input.itemId ? { itemId: input.itemId } : {}),
4208
+ ...(input.x !== undefined ? { x: input.x } : {}),
4209
+ ...(input.y !== undefined ? { y: input.y } : {}),
4210
+ ...(input.time !== undefined ? { time: input.time } : {}),
4211
+ }), 'Comment: add');
4212
+ case 'list':
4213
+ return this._facadeCall('listComments', JSON.stringify({
4214
+ ...(input.time !== undefined ? { time: input.time } : {}),
4215
+ ...(input.includeResolved ? { includeResolved: true } : {}),
4216
+ }), 'Comment: list');
4217
+ case 'resolve':
4218
+ return this._facadeCall('resolveComment', `${JSON.stringify(input.id || '')}, ${input.resolved === false ? 'false' : 'true'}`, 'Comment: resolve');
4219
+ case 'delete':
4220
+ return this._facadeCall('deleteComment', JSON.stringify(input.id || ''), 'Comment: delete');
4221
+ }
4222
+ }
4223
+ generateProvenance(input) {
4224
+ const id = JSON.stringify(input.itemId);
4225
+ switch (input.action) {
4226
+ case 'get': return this._facadeCall('getItemProvenance', id, 'Provenance: get');
4227
+ case 'lineage': return this._facadeCall('getLineage', id, 'Provenance: lineage');
4228
+ case 'dependents': return this._facadeCall('getDependents', id, 'Provenance: dependents');
4229
+ case 'record':
4230
+ return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derived')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
4231
+ }
4232
+ }
4233
+ generateCompose(input) {
4234
+ switch (input.action) {
4235
+ case 'list_patterns':
4236
+ return this._facadeCall('listCollagePatterns', '', 'Compose: list patterns');
4237
+ case 'list_treatments':
4238
+ return this._facadeCall('listCameraTreatments', '', 'Compose: list treatments');
4239
+ case 'list_reveals':
4240
+ return this._facadeCall('listReveals', '', 'Compose: list reveals');
4241
+ case 'list_styles':
4242
+ return this._facadeCall('listStyles', '', 'Compose: list styles');
4243
+ case 'set_treatment':
4244
+ return this._facadeCall('setCollageTreatment', `${JSON.stringify(input.rootId || '')}, ${JSON.stringify(input.treatment || '')}, ${JSON.stringify({ ...(input.loop ? { loop: true } : {}) })}`, 'Compose: set treatment');
4245
+ case 'apply':
4246
+ default:
4247
+ // Forward the WHOLE surface. The first version forwarded only
4248
+ // treatment/camera/loop/craft — the temporal (reveal), style, text,
4249
+ // vector and audio halves of composeCollage were unreachable through
4250
+ // MCP, so agents converged on the four static patterns the description
4251
+ // happened to name. A capability gap reads as model bias from outside.
4252
+ return this._facadeCall('composeCollage', `${JSON.stringify(input.pattern || '')}, ${JSON.stringify(input.itemIds || [])}, ${JSON.stringify({
4253
+ ...(input.treatment ? { treatment: input.treatment } : {}),
4254
+ ...(input.applyCamera === false ? { applyCamera: false } : {}),
4255
+ ...(input.loop ? { loop: true } : {}),
4256
+ ...(input.craft ? { craft: input.craft } : {}),
4257
+ ...(input.reveal !== undefined ? { reveal: input.reveal } : {}),
4258
+ ...(input.revealOptions ? { revealOptions: input.revealOptions } : {}),
4259
+ ...(input.style ? { style: input.style } : {}),
4260
+ ...(input.text ? { text: input.text } : {}),
4261
+ ...(input.assets ? { assets: input.assets } : {}),
4262
+ ...(input.audio ? { audio: input.audio } : {}),
4263
+ ...(input.grid ? { grid: true } : {}),
4264
+ })}`, 'Compose: apply pattern');
4265
+ }
4266
+ }
4267
+ generateAudioBeats(input) {
4268
+ if (input.action === 'analyze') {
4269
+ return this._facadeCall('analyzeAudio', `${JSON.stringify(input.source || '')}, ${JSON.stringify({
4270
+ ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
4271
+ ...(input.minGap !== undefined ? { minGap: input.minGap } : {}),
4272
+ })}`, 'Audio: analyze beats');
4273
+ }
4274
+ return this._facadeCall('animateToBeat', `${JSON.stringify(input.itemId || '')}, ${JSON.stringify({
4275
+ ...(input.source ? { source: input.source } : {}),
4276
+ ...(input.beats ? { beats: input.beats } : {}),
4277
+ ...(input.grid ? { grid: true } : {}),
4278
+ ...(input.property ? { property: input.property } : {}),
4279
+ ...(input.base !== undefined ? { base: input.base } : {}),
4280
+ ...(input.accent !== undefined ? { accent: input.accent } : {}),
4281
+ ...(input.decay !== undefined ? { decay: input.decay } : {}),
4282
+ ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
4283
+ })}`, 'Audio: animate to beat');
4284
+ }
4285
+ generateTemplateParams(input) {
4286
+ if (input.action === 'get') {
4287
+ return this._facadeCall('getTemplateParams', JSON.stringify(input.templateId), 'Template params: get');
4288
+ }
4289
+ return this._facadeCall('applyTemplateWithParams', `${JSON.stringify(input.templateId)}, ${JSON.stringify(input.params || {})}`, 'Template params: apply');
4290
+ }
4291
+ generateSceneDiff(input) {
4292
+ if (input.action === 'version') {
4293
+ return this._facadeCall('diffAgainstVersion', JSON.stringify(input.versionId || ''), 'Scene diff: vs version');
4294
+ }
4295
+ return this._facadeCall('diffHistoryStates', `${input.indexA ?? 0}, ${input.indexB ?? 0}`, 'Scene diff: history');
4296
+ }
3993
4297
  generateTransform(input) {
3994
4298
  switch (input.action) {
4299
+ case 'fit': {
4300
+ const mode = input.mode || 'contain';
4301
+ return `
4302
+ // Fit item to the export frame (${mode})
4303
+ (function() {
4304
+ // Route through the agent facade, not app.fitToFrame directly: an agent that
4305
+ // just called upload_video holds a MEDIA id (vraster_…), which is a different
4306
+ // id space from canvas item ids. PinePaperAgent.fitToFrame resolves either.
4307
+ const A = (typeof window !== 'undefined') && window.PinePaperAgent;
4308
+ if (A && typeof A.fitToFrame === 'function') {
4309
+ const ok = A.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
4310
+ return ok
4311
+ ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
4312
+ : { success: false, error: 'Item not found or not fittable: ${input.itemId}' };
4313
+ }
4314
+ if (typeof app.fitToFrame !== 'function') {
4315
+ return { success: false, error: 'fitToFrame unavailable — update FxTool' };
4316
+ }
4317
+ const ok = app.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
4318
+ return ok
4319
+ ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
4320
+ : { success: false, error: 'Item not found: ${input.itemId}' };
4321
+ })();`.trim();
4322
+ }
3995
4323
  case 'nudge': {
3996
4324
  const dx = input.dx ?? 0;
3997
4325
  const dy = input.dy ?? 0;
@@ -4123,6 +4451,32 @@ ${resolve}
4123
4451
  })();`.trim();
4124
4452
  default:
4125
4453
  return `(function() { return { error: 'Unknown image_filter action: ${input.action}' }; })();`;
4454
+ case 'analyze_palette':
4455
+ return `
4456
+ // Analyze palette: the dominant colours, largest area first (GPU path)
4457
+ (async function() {
4458
+ if (typeof app.analyzePalette !== 'function') { return { success: false, error: 'app.analyzePalette unavailable — update FxTool to a palette-capable build' }; }
4459
+ try {
4460
+ const r = await app.analyzePalette(${JSON.stringify(input.itemId)}, ${JSON.stringify({ ...(input.maxSwatches !== undefined ? { maxSwatches: input.maxSwatches } : {}) })});
4461
+ // swatches: [{hex, share, …}] largest-first — feed hex values straight into
4462
+ // recolor_palette mapping or the paletteMap filter's swatches[].
4463
+ return { success: true, width: r.width, height: r.height, opaquePixels: r.opaquePixels, swatches: r.swatches };
4464
+ } catch (e) {
4465
+ return { success: false, error: e && e.message ? e.message : String(e) };
4466
+ }
4467
+ })();`.trim();
4468
+ case 'recolor_palette':
4469
+ return `
4470
+ // Recolor palette: swap colours, keep the shading (GPU path)
4471
+ (async function() {
4472
+ if (typeof app.recolorPalette !== 'function') { return { success: false, error: 'app.recolorPalette unavailable — update FxTool to a palette-capable build' }; }
4473
+ try {
4474
+ await app.recolorPalette(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.mapping)}, ${JSON.stringify({ ...(input.amount !== undefined ? { amount: input.amount } : {}), ...(input.preserveShading !== undefined ? { preserveShading: input.preserveShading } : {}) })});
4475
+ return { success: true, action: 'recolor_palette', itemId: ${JSON.stringify(input.itemId)} };
4476
+ } catch (e) {
4477
+ return { success: false, error: e && e.message ? e.message : String(e) };
4478
+ }
4479
+ })();`.trim();
4126
4480
  }
4127
4481
  }
4128
4482
  generateLasso(input) {
@@ -5228,6 +5582,338 @@ ${guard}
5228
5582
  if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
5229
5583
  const ok = A.setMediaClip(${JSON.stringify(input.id)}, ${input.inPoint}, ${input.outPoint});
5230
5584
  return { success: ok, action: 'set_clip', id: ${JSON.stringify(input.id)}, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} };
5585
+ })();`.trim();
5586
+ // ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
5587
+ // facades), not the agent media API, so they carry their own guards.
5588
+ // The facades snapshot history themselves; no saveState here.
5589
+ case 'set_time_remap': {
5590
+ const S = (v) => JSON.stringify(v);
5591
+ return `
5592
+ // Remap a clip's time — speed ramps, freeze frames, reverse (null clears)
5593
+ (function() {
5594
+ if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
5595
+ const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
5596
+ return r && r.ok ? { success: true, action: 'set_time_remap', points: r.points } : { success: false, error: (r && r.error) || 'remap failed' };
5597
+ })();`.trim();
5598
+ }
5599
+ case 'speed_ramp': {
5600
+ const S = (v) => JSON.stringify(v);
5601
+ return `
5602
+ // Speed ramp: consecutive {duration, speed} segments compiled to a remap curve
5603
+ (function() {
5604
+ if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
5605
+ const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
5606
+ return r && r.ok ? { success: true, action: 'speed_ramp', points: r.points } : { success: false, error: (r && r.error) || 'speed ramp failed' };
5607
+ })();`.trim();
5608
+ }
5609
+ case 'match_cut': {
5610
+ const S = (v) => JSON.stringify(v);
5611
+ const opts = S({
5612
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
5613
+ ...(input.label !== undefined ? { label: input.label } : {}),
5614
+ ...(input.at !== undefined ? { at: input.at } : {}),
5615
+ ...(input.settle !== undefined ? { settle: input.settle } : {}),
5616
+ ...(input.fade !== undefined ? { fade: input.fade } : {}),
5617
+ ...(input.consent !== undefined ? { consent: input.consent } : {}),
5618
+ });
5619
+ return `
5620
+ // Match cut: align the SUBJECT across the cut (on-device detection)
5621
+ (async function() {
5622
+ if (typeof app.matchCut !== 'function') { return { success: false, error: 'app.matchCut unavailable — update FxTool to a match-cut-capable build' }; }
5623
+ const r = await app.matchCut(${S(input.fromItemId)}, ${S(input.toItemId)}, ${opts});
5624
+ if (r && r.needsConsent) { return { success: false, needsConsent: true, cost: r.cost, error: 'Detection model download needs consent — re-call with consent: true' }; }
5625
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'match cut failed' }; }
5626
+ return { success: true, action: 'match_cut', aspectMismatch: r.aspectMismatch || false };
5627
+ })();`.trim();
5628
+ }
5629
+ case 'apply_track_matte': {
5630
+ const S = (v) => JSON.stringify(v);
5631
+ const opts = S({
5632
+ ...(input.channel !== undefined ? { channel: input.channel } : {}),
5633
+ ...(input.invert !== undefined ? { invert: input.invert } : {}),
5634
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
5635
+ ...(input.hideMatte !== undefined ? { hideMatte: input.hideMatte } : {}),
5636
+ ...(input.live !== undefined ? { live: input.live } : {}),
5637
+ });
5638
+ return `
5639
+ // Track matte: the matte item's ${input.channel || 'luma'} drives the target's alpha${input.live ? ' (LIVE — re-cuts as the matte animates)' : ''}
5640
+ (async function() {
5641
+ if (typeof app.applyTrackMatte !== 'function') { return { success: false, error: 'app.applyTrackMatte unavailable — update FxTool to a track-matte-capable build' }; }
5642
+ try {
5643
+ await app.applyTrackMatte(${S(input.id)}, ${S(input.matteItemId)}, ${opts});
5644
+ return { success: true, action: 'apply_track_matte', id: ${S(input.id)}, matte: ${S(input.matteItemId)}, live: ${S(!!input.live)} };
5645
+ } catch (e) {
5646
+ // The facade throws on a missing raster/matte rather than returning {ok:false}.
5647
+ return { success: false, error: e && e.message ? e.message : String(e) };
5648
+ }
5649
+ })();`.trim();
5650
+ }
5651
+ case 'stop_live_matte': {
5652
+ const S = (v) => JSON.stringify(v);
5653
+ return `
5654
+ // Stop a live matte, leaving the last cut in place
5655
+ (function() {
5656
+ if (typeof app.stopLiveMatte !== 'function') { return { success: false, error: 'app.stopLiveMatte unavailable — update FxTool' }; }
5657
+ const r = app.stopLiveMatte(${S(input.id)});
5658
+ return r && r.ok ? { success: true, action: 'stop_live_matte' } : { success: false, error: (r && r.error) || 'no live matte on that item' };
5659
+ })();`.trim();
5660
+ }
5661
+ }
5662
+ }
5663
+ /**
5664
+ * pinepaper_text_style — display styles + variable-font axes.
5665
+ * All three facades return their own {ok, …} envelopes; forwarded as-is.
5666
+ */
5667
+ generateTextStyle(input) {
5668
+ const S = (v) => JSON.stringify(v);
5669
+ switch (input.action) {
5670
+ case 'apply_style': {
5671
+ const opts = S({
5672
+ ...(input.palette !== undefined ? { palette: input.palette } : {}),
5673
+ ...(input.variant !== undefined ? { variant: input.variant } : {}),
5674
+ ...(input.content !== undefined ? { content: input.content } : {}),
5675
+ ...(input.fontFamily !== undefined ? { fontFamily: input.fontFamily } : {}),
5676
+ ...(input.fontSize !== undefined ? { fontSize: input.fontSize } : {}),
5677
+ });
5678
+ return `
5679
+ // Display text style: ${input.styleKey} — stacked-layer title, id adopted from the text
5680
+ (function() {
5681
+ if (typeof app.applyTextStyle !== 'function') { return { success: false, error: 'app.applyTextStyle unavailable — update FxTool to a text-styles build' }; }
5682
+ const r = app.applyTextStyle(${S(input.itemId)}, ${S(input.styleKey)}, ${opts});
5683
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'style failed' }; }
5684
+ // The styled group ADOPTS the text item's registry id — r.id is the SAME id
5685
+ // the caller passed, so their handle keeps working. Say so in the result.
5686
+ return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers, palette: r.palette, bounds: r.bounds };
5687
+ })();`.trim();
5688
+ }
5689
+ case 'set_font_axes': {
5690
+ return `
5691
+ // Variable-font axes (standard trio: weight/width/slant — Canvas 2D has no custom axes)
5692
+ (function() {
5693
+ if (typeof app.setFontAxes !== 'function') { return { success: false, error: 'app.setFontAxes unavailable — update FxTool to a variable-font build' }; }
5694
+ const r = app.setFontAxes(${S(input.itemId)}, ${S(input.axes)});
5695
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'axes failed' }; }
5696
+ // rejected is the interesting half: an axis silently ignored is the failure
5697
+ // mode this surface exists to prevent.
5698
+ return { success: true, action: 'set_font_axes', applied: r.applied, rejected: r.rejected || [] };
5699
+ })();`.trim();
5700
+ }
5701
+ case 'list_styles': {
5702
+ return `
5703
+ // Text styles + palettes + font axes — the picker surface
5704
+ (function() {
5705
+ if (typeof app.listTextStyles !== 'function') { return { success: false, error: 'app.listTextStyles unavailable — update FxTool to a text-styles build' }; }
5706
+ return {
5707
+ success: true,
5708
+ styles: app.listTextStyles(),
5709
+ palettes: typeof app.listTextPalettes === 'function' ? app.listTextPalettes() : [],
5710
+ fontAxes: typeof app.listFontAxes === 'function' ? app.listFontAxes() : null,
5711
+ };
5712
+ })();`.trim();
5713
+ }
5714
+ }
5715
+ }
5716
+ /**
5717
+ * Shatter a raster into a grid of tiles. The group INHERITS the original's
5718
+ * registry id (relations/handles keep pointing at the thing in its place),
5719
+ * and the result is deliberately inert until something animates the pieces.
5720
+ */
5721
+ generateShatterImage(input) {
5722
+ const S = (v) => JSON.stringify(v);
5723
+ const opts = S({
5724
+ ...(input.pieces !== undefined ? { pieces: input.pieces } : {}),
5725
+ ...(input.rows !== undefined ? { rows: input.rows } : {}),
5726
+ ...(input.cols !== undefined ? { cols: input.cols } : {}),
5727
+ ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
5728
+ });
5729
+ return `
5730
+ // Shatter ${input.itemId} into tiles (inert until animated — that is the point)
5731
+ (function() {
5732
+ if (typeof app.shatterImage !== 'function') { return { success: false, error: 'app.shatterImage unavailable — update FxTool to a shatter-capable build' }; }
5733
+ const r = app.shatterImage(${S(input.itemId)}, ${opts});
5734
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'shatter failed' }; }
5735
+ // groupId === the original item's id (adopted). tiles/rows/cols tell the
5736
+ // caller what grid they actually got — 100 pieces of a 3:2 photo is 12x8=96.
5737
+ return { success: true, groupId: r.groupId, tiles: r.tiles, rows: r.rows, cols: r.cols };
5738
+ })();`.trim();
5739
+ }
5740
+ /**
5741
+ * Import a decomposed character (layer manifest + per-layer images) as a
5742
+ * role-bound Group. The facade is async and does its own heavy-module
5743
+ * loading (the cold-boot rigging-rules trap is handled inside it).
5744
+ */
5745
+ generateImportLayeredCharacter(input) {
5746
+ const S = (v) => JSON.stringify(v);
5747
+ const opts = S({
5748
+ ...(input.position !== undefined ? { position: input.position } : {}),
5749
+ ...(input.scale !== undefined ? { scale: input.scale } : {}),
5750
+ ...(input.name !== undefined ? { name: input.name } : {}),
5751
+ });
5752
+ return `
5753
+ // Import a layered character (decomposer output → role-bound parts; blink works out of the box)
5754
+ (async function() {
5755
+ if (typeof app.importLayeredCharacter !== 'function') { return { success: false, error: 'app.importLayeredCharacter unavailable — update FxTool to a layered-character build' }; }
5756
+ // The importer's contract is a Map (it calls images.get) — the wire format is
5757
+ // a plain object, so rebuild the Map here.
5758
+ const bundle = { info: ${S(input.info)}, images: new Map(Object.entries(${S(input.images)})) };
5759
+ const r = await app.importLayeredCharacter(bundle, ${opts});
5760
+ if (!r || !r.groupId) { return { success: false, error: (r && r.error) || 'layered character import failed' }; }
5761
+ // roles maps part roles → item ids; zero roles wired means the character
5762
+ // renders but will NOT animate — surface the count so that cannot hide.
5763
+ return { success: true, groupId: r.groupId, parts: (r.parts || []).length, roles: r.roles || {}, rolesWired: Object.keys(r.roles || {}).length, warnings: r.warnings || [] };
5764
+ })();`.trim();
5765
+ }
5766
+ /**
5767
+ * pinepaper_game — pathfinding + tilemaps via the PinePaper facades.
5768
+ * Both are pure data ops; the interesting glue is in the doc: paths feed
5769
+ * moves_along_path, collisionRects feed pinepaper_physics.
5770
+ */
5771
+ generateGame(input) {
5772
+ const S = (v) => JSON.stringify(v);
5773
+ switch (input.action) {
5774
+ case 'pathfind': {
5775
+ const opts = S({ ...(input.diagonal !== undefined ? { diagonal: input.diagonal } : {}) });
5776
+ return `
5777
+ // A* pathfind — waypoints for moves_along_path
5778
+ (async function() {
5779
+ if (typeof app.findPath !== 'function') { return { success: false, error: 'app.findPath unavailable — update FxTool to a games-capable build' }; }
5780
+ const r = await app.findPath(${S(input.grid)}, ${S(input.start)}, ${S(input.goal)}, ${opts});
5781
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'pathfind failed' }; }
5782
+ // path is world-space [{x,y}] — feed it DIRECTLY to pinepaper_add_relation
5783
+ // moves_along_path params.path, or to a keyframe track.
5784
+ return { success: true, path: r.path, waypoints: r.path.length };
5785
+ })();`.trim();
5786
+ }
5787
+ case 'create_tilemap': {
5788
+ const spec = S({
5789
+ cols: input.cols, rows: input.rows,
5790
+ ...(input.tileSize !== undefined ? { tileSize: input.tileSize } : {}),
5791
+ ...(input.origin !== undefined ? { origin: input.origin } : {}),
5792
+ ...(input.tileset !== undefined ? { tileset: input.tileset } : {}),
5793
+ ...(input.fills !== undefined ? { fills: input.fills } : {}),
5794
+ });
5795
+ return `
5796
+ // Tilemap: board data + pathfinding grid + merged collision rects
5797
+ (async function() {
5798
+ if (typeof app.createTilemap !== 'function') { return { success: false, error: 'app.createTilemap unavailable — update FxTool to a games-capable build' }; }
5799
+ const r = await app.createTilemap(${spec});
5800
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'tilemap failed' }; }
5801
+ // map: persist it (item.data / project document) — it is DATA, not items;
5802
+ // grid: pass straight back into pathfind; collisionRects: pinepaper_physics
5803
+ // bodies (greedy-merged, so a 60x1 wall is ONE rect, not 60).
5804
+ return { success: true, map: r.map, grid: r.grid, collisionRects: r.collisionRects, rectCount: r.collisionRects.length };
5805
+ })();`.trim();
5806
+ }
5807
+ }
5808
+ }
5809
+ /**
5810
+ * pinepaper_world3d — every action rides the PinePaper world facades. The
5811
+ * world is lazily created by createWorld3D; every other action guards on the
5812
+ * facade AND on a world existing, with the create hint in the error.
5813
+ */
5814
+ generateWorld3D(input) {
5815
+ const S = (v) => JSON.stringify(v);
5816
+ const needWorld = ` if (!app._world3d) { return { success: false, error: "no 3D world — call pinepaper_world3d create first" }; }`;
5817
+ switch (input.action) {
5818
+ case 'create': {
5819
+ const opts = S({ ...(input.character !== undefined ? { character: input.character } : {}) });
5820
+ return `
5821
+ // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
5822
+ (async function() {
5823
+ if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
5824
+ await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
5825
+ // describe() is the world's own parameter schema — return the preset list +
5826
+ // top-level keys so the agent knows what configure can touch, without the
5827
+ // full multi-KB schema on every create.
5828
+ const d = typeof app.describeWorld3D === 'function' ? await app.describeWorld3D() : null;
5829
+ return { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: d ? Object.keys(d.params || d) : [] };
5830
+ })();`.trim();
5831
+ }
5832
+ case 'describe':
5833
+ return `
5834
+ // World3D: the engine's own parameter schema — types, ranges, descriptions
5835
+ (async function() {
5836
+ if (typeof app.describeWorld3D !== 'function') { return { success: false, error: 'app.describeWorld3D unavailable — update FxTool' }; }
5837
+ return { success: true, schema: await app.describeWorld3D() };
5838
+ })();`.trim();
5839
+ case 'configure':
5840
+ return `
5841
+ // World3D: live configure (deep-merged, schema-validated)
5842
+ (function() {
5843
+ if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
5844
+ ${needWorld}
5845
+ const r = app.configureWorld3D(${S(input.patch)});
5846
+ // The validator names the right key on a wrong one — forward it verbatim.
5847
+ return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
5848
+ })();`.trim();
5849
+ case 'add_actor': {
5850
+ const actor = S({
5851
+ ...(input.actorId !== undefined ? { id: input.actorId } : {}),
5852
+ ...(input.x !== undefined ? { x: input.x } : {}),
5853
+ ...(input.z !== undefined ? { z: input.z } : {}),
5854
+ ...(input.height !== undefined ? { height: input.height } : {}),
5855
+ ...(input.sprite !== undefined ? { sprite: input.sprite } : {}),
5856
+ ...(input.live !== undefined ? { live: input.live } : {}),
5857
+ });
5858
+ return `
5859
+ // World3D: put an actor on the stage${input.live ? ' (live sprite — a rigged character PERFORMS, not a photograph of itself)' : ''}
5860
+ (function() {
5861
+ if (typeof app.addWorldActor !== 'function') { return { success: false, error: 'app.addWorldActor unavailable — update FxTool' }; }
5862
+ ${needWorld}
5863
+ const id = app.addWorldActor(${actor});
5864
+ return id ? { success: true, actorId: id } : { success: false, error: 'actor not added — is the sprite id a canvas item?' };
5865
+ })();`.trim();
5866
+ }
5867
+ case 'remove_actor':
5868
+ return `
5869
+ // World3D: remove actor
5870
+ (function() {
5871
+ ${needWorld}
5872
+ return { success: !!app.removeWorldActor(${S(input.actorId)}), actorId: ${S(input.actorId)} };
5873
+ })();`.trim();
5874
+ case 'list_actors':
5875
+ return `
5876
+ // World3D: the addressable stage
5877
+ (function() {
5878
+ ${needWorld}
5879
+ return { success: true, actors: app.listWorldActors() };
5880
+ })();`.trim();
5881
+ case 'set_actor_pose':
5882
+ return `
5883
+ // World3D: pose an actor — the setter a timeline, sequencer or agent drives
5884
+ (function() {
5885
+ ${needWorld}
5886
+ return { success: !!app.setWorldActorPose(${S(input.actorId)}, ${S(input.pose)}), actorId: ${S(input.actorId)} };
5887
+ })();`.trim();
5888
+ case 'set_camera':
5889
+ return `
5890
+ // World3D: direct the camera (follow | fixed | orbit)
5891
+ (function() {
5892
+ ${needWorld}
5893
+ const r = app.setWorldCamera(${S(input.camera)});
5894
+ return r === false ? { success: false, error: 'camera config rejected' } : { success: true };
5895
+ })();`.trim();
5896
+ case 'add_object':
5897
+ return `
5898
+ // World3D: place an object (y defaults to sitting on the terrain)
5899
+ (function() {
5900
+ ${needWorld}
5901
+ const id = app.addWorldObject(${S(input.object)});
5902
+ return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
5903
+ })();`.trim();
5904
+ case 'remove_object':
5905
+ return `
5906
+ // World3D: remove object
5907
+ (function() {
5908
+ ${needWorld}
5909
+ return { success: !!app.removeWorldObject(${S(input.objectId)}), objectId: ${S(input.objectId)} };
5910
+ })();`.trim();
5911
+ case 'remove_world':
5912
+ return `
5913
+ // World3D: tear the world down — Paper's canvas is untouched (it was always its own layer)
5914
+ (function() {
5915
+ if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
5916
+ return { success: !!app.removeWorld3D() };
5231
5917
  })();`.trim();
5232
5918
  }
5233
5919
  }
@@ -5345,6 +6031,61 @@ ${guard}
5345
6031
  return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
5346
6032
  if (app.historyManager) app.historyManager.saveState();
5347
6033
  return { success: !!shapeKeyId, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
6034
+ // ── Mocap / rig import — these live on `app` (PinePaper facades), not
6035
+ // riggingSystem, and they are async, so they get their own async IIFE
6036
+ // instead of the sync `wrap` above. Each returns the facade's own result
6037
+ // object: it already carries {ok, error, warnings…} and inventing a
6038
+ // second envelope here would just hide the interesting fields.
6039
+ case 'import_bvh': {
6040
+ const opts = S({
6041
+ ...(input.view !== undefined ? { view: input.view } : {}),
6042
+ ...(input.fps !== undefined ? { fps: input.fps } : {}),
6043
+ ...(input.height !== undefined ? { height: input.height } : {}),
6044
+ ...(input.rootPosition !== undefined ? { position: input.rootPosition } : {}),
6045
+ ...(input.name !== undefined ? { name: input.name } : {}),
6046
+ });
6047
+ return `
6048
+ // Rigging: import BVH mocap as a new rig (stick figure included)
6049
+ (async function() {
6050
+ if (typeof app.importBVH !== 'function') { return { success: false, error: 'app.importBVH unavailable — update FxTool to a BVH-capable build' }; }
6051
+ const r = await app.importBVH(${S(input.bvhText ?? '')}, ${opts});
6052
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'BVH import failed' }; }
6053
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6054
+ return { success: true, action: 'import_bvh', skeletonId: r.skeletonId, poses: r.poses, duration: r.duration, warnings: r.warnings || [] };
6055
+ })();`.trim();
6056
+ }
6057
+ case 'retarget_bvh': {
6058
+ const opts = S({
6059
+ ...(input.fps !== undefined ? { fps: input.fps } : {}),
6060
+ ...(input.name !== undefined ? { name: input.name } : {}),
6061
+ });
6062
+ return `
6063
+ // Rigging: retarget a BVH clip onto an EXISTING rig (matched by bone name)
6064
+ (async function() {
6065
+ if (typeof app.retargetBVH !== 'function') { return { success: false, error: 'app.retargetBVH unavailable — update FxTool to a BVH-capable build' }; }
6066
+ const r = await app.retargetBVH(${S(input.bvhText ?? '')}, ${S(input.skeletonId)}, ${opts});
6067
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'BVH retarget failed' }; }
6068
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6069
+ // matched/unmatched matter: a retarget that matched 2 of 15 bones "succeeded"
6070
+ // and looks broken — the caller needs the numbers to know which happened.
6071
+ return { success: true, action: 'retarget_bvh', skeletonId: r.skeletonId, matched: r.matched, unmatchedSource: r.unmatchedSource, unmatchedTarget: r.unmatchedTarget, poses: r.poses, duration: r.duration, warnings: r.warnings || [] };
6072
+ })();`.trim();
6073
+ }
6074
+ case 'import_spine': {
6075
+ const opts = S({
6076
+ ...(input.rootPosition !== undefined ? { position: input.rootPosition } : {}),
6077
+ ...(input.name !== undefined ? { name: input.name } : {}),
6078
+ });
6079
+ return `
6080
+ // Rigging: import a Spine JSON export (bones, poses, attachment placeholders)
6081
+ (async function() {
6082
+ if (typeof app.importSpine !== 'function') { return { success: false, error: 'app.importSpine unavailable — update FxTool to a Spine-capable build' }; }
6083
+ const r = await app.importSpine(${S(input.spineJson ?? '')}, ${opts});
6084
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'Spine import failed' }; }
6085
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6086
+ return { success: true, action: 'import_spine', skeletonId: r.skeletonId, bones: r.bones, placeholders: r.placeholders, animations: (r.animations || []).map(function(a) { return a.name; }), warnings: r.warnings || [] };
6087
+ })();`.trim();
6088
+ }
5348
6089
  }
5349
6090
  }
5350
6091
  /**