@pinepaper.studio/mcp-server 1.6.2 → 1.6.4

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.
@@ -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
  }
@@ -2121,7 +2160,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2121
2160
  */
2122
2161
  generateAgentExport(input) {
2123
2162
  const validated = AgentExportInputSchema.parse(input);
2124
- const { platform, format, quality, framing, duration } = validated;
2163
+ const { platform, format, quality, framing, duration, estimateOnly } = validated;
2125
2164
  const qualityLevel = quality || 'standard';
2126
2165
  const videoDuration = duration ?? 5;
2127
2166
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -2156,6 +2195,33 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2156
2195
  const settings = ${JSON.stringify(qualitySettings)};
2157
2196
  const dimensions = ${JSON.stringify({ width: preset.width, height: preset.height })};
2158
2197
 
2198
+ // Preflight: same resolved settings as the real export, but render nothing.
2199
+ // Branching HERE rather than in a separate tool is deliberate — the estimate
2200
+ // has to see the same platform dimensions, fps and quality the export would
2201
+ // use, and a parallel tool would re-derive them and drift.
2202
+ if (${estimateOnly ? 'true' : 'false'}) {
2203
+ if (typeof app.estimateExportSize !== 'function') {
2204
+ return { success: false, estimateOnly: true, error: 'This PinePaper build has no estimateExportSize() — update the editor to preflight export size.' };
2205
+ }
2206
+ const est = await app.estimateExportSize({
2207
+ format,
2208
+ duration: ${videoDuration},
2209
+ fps: settings.fps,
2210
+ quality,
2211
+ width: dimensions.width,
2212
+ height: dimensions.height,
2213
+ });
2214
+ return {
2215
+ success: true,
2216
+ estimateOnly: true,
2217
+ platform, format, quality,
2218
+ duration: ${videoDuration},
2219
+ fps: settings.fps,
2220
+ dimensions: dimensions.width + 'x' + dimensions.height,
2221
+ estimate: est,
2222
+ };
2223
+ }
2224
+
2159
2225
  // Resolve camera-view framing dims when requested. Reads the first
2160
2226
  // camera_animates keyframe's zoom and divides the canvas dims by it
2161
2227
  // (mirrors FxTool ExportEngine.js:466-485). Camera animation still
@@ -3243,12 +3309,20 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3243
3309
  const relations = [];
3244
3310
  const decorative = [];
3245
3311
 
3246
- // Collect items from registry
3247
- if (app.itemRegistry) {
3248
- for (const [id, item] of app.itemRegistry.entries()) {
3312
+ // Collect items from registry.
3313
+ //
3314
+ // ItemRegistry exposes get/getAll — never \`entries()\`. Iterating a
3315
+ // non-existent method threw on EVERY call, so this tool has never once
3316
+ // returned a scene. getAll() yields registry ENTRIES
3317
+ // ({ itemId, item, type, properties }), not bare Paper items, so the
3318
+ // geometry has to be read off entry.item.
3319
+ if (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
3320
+ for (const entry of app.itemRegistry.getAll()) {
3321
+ const item = entry.item;
3322
+ if (!item) continue;
3249
3323
  const itemData = {
3250
- id: id,
3251
- type: item.data?.type || item.className?.toLowerCase() || 'unknown',
3324
+ id: entry.itemId,
3325
+ type: entry.type || item.data?.type || item.className?.toLowerCase() || 'unknown',
3252
3326
  position: item.position ? { x: item.position.x, y: item.position.y } : null,
3253
3327
  bounds: item.bounds ? {
3254
3328
  x: item.bounds.x,
@@ -3266,6 +3340,30 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3266
3340
  if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3267
3341
  if (item.rotation) itemData.properties.rotation = item.rotation;
3268
3342
  if (item.data?.content) itemData.properties.content = item.data.content;
3343
+ if (item.visible === false) itemData.properties.visible = false;
3344
+
3345
+ // THE SECOND TIMELINE. A video/audio item carries its own clip window
3346
+ // (media-time in/out) plus where that window sits on the project
3347
+ // timeline. Without it a consumer sees "there is a video here" and has
3348
+ // no idea which 5 seconds of it play, or when.
3349
+ const p = entry.properties || {};
3350
+ if (p.inPoint !== undefined || p.outPoint !== undefined || p.clipStartTime !== undefined) {
3351
+ itemData.clip = {
3352
+ clipStartTime: p.clipStartTime ?? 0, // project-timeline start, s
3353
+ inPoint: p.inPoint ?? 0, // media-time in, s
3354
+ outPoint: p.outPoint ?? null // media-time out, s
3355
+ };
3356
+ }
3357
+
3358
+ // When this item is on screen at all, merged across keyframe clip
3359
+ // window / loop / mask / relation windows / effects / media clip.
3360
+ // null end = open-ended. Static items report null.
3361
+ if (typeof app.activeWindowOf === 'function') {
3362
+ try {
3363
+ const w = app.activeWindowOf(entry.itemId);
3364
+ if (w) itemData.activeWindow = w;
3365
+ } catch (_) { /* non-fatal — an item without a window is just static */ }
3366
+ }
3269
3367
 
3270
3368
  if (item.data?.isDecorative) {
3271
3369
  decorative.push(itemData);
@@ -3275,14 +3373,19 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3275
3373
  }
3276
3374
  }
3277
3375
 
3278
- // Collect relations
3279
- if (app.getRelations) {
3280
- const activeRelations = app.getRelations();
3281
- for (const rel of activeRelations) {
3376
+ // Collect relations — the EDGE LIST, which with the items above is the
3377
+ // scene DAG.
3378
+ //
3379
+ // \`app.getRelations()\` takes an itemId; called bare it returns [], so the
3380
+ // old code emitted an empty edge list for every scene. exportForSave() is
3381
+ // the canonical whole-graph dump (the same one history persistence uses)
3382
+ // and returns { fromId, toId, relation, params }.
3383
+ if (app.relationRegistry && typeof app.relationRegistry.exportForSave === 'function') {
3384
+ for (const rel of app.relationRegistry.exportForSave()) {
3282
3385
  relations.push({
3283
- sourceId: rel.sourceId,
3284
- targetId: rel.targetId,
3285
- type: rel.type,
3386
+ sourceId: rel.fromId,
3387
+ targetId: rel.toId ?? null, // null = self-relation, not a missing edge
3388
+ type: rel.relation,
3286
3389
  params: rel.params
3287
3390
  });
3288
3391
  }
@@ -3698,9 +3801,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3698
3801
  }
3699
3802
  generateImportImage(input) {
3700
3803
  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}` : '';
3804
+ // Each fragment used to be written with a LEADING comma, as if it followed
3805
+ // an entry that no longer exists — so the first one opened the object with
3806
+ // `{, position: …}` and the emitted code was a syntax error. Passing any
3807
+ // option at all therefore failed 100% of the time, which is why imported
3808
+ // images could never be positioned, sized or masked. Join instead, so the
3809
+ // separators come from the number of options rather than from each one.
3810
+ const optParts = [
3811
+ position ? `position: { x: ${position.x}, y: ${position.y} }` : '',
3812
+ maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
3813
+ maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
3814
+ ].filter(Boolean);
3815
+ const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
3704
3816
  return `
3705
3817
  // Import image from URL
3706
3818
  (async function() {
@@ -3709,9 +3821,20 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3709
3821
  }
3710
3822
  try {
3711
3823
  const entry = await app.imageTools.uploadFromURL('${url}');
3712
- const opts = {${positionOpts}${maxWidthOpt}${maxHeightOpt} };
3824
+ const opts = ${optsLiteral};
3713
3825
  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;
3826
+ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
3827
+ // fell through to \`raster.id\` — a Paper.js NUMBER — and the handle the tool
3828
+ // returned could not be used as a relation endpoint or with modify/animate.
3829
+ // Register the raster if placeImage somehow left it unregistered, so the
3830
+ // caller always gets a usable id rather than a number that looks like one.
3831
+ let itemId = raster.data?.id;
3832
+ if (!itemId && typeof app.registerItem === 'function') {
3833
+ itemId = app.registerItem(raster, 'image', { source: 'mcp-import' });
3834
+ }
3835
+ if (!itemId) {
3836
+ return { error: 'Image placed but not registered — no usable item id. This is a bug; report the scene.' };
3837
+ }
3715
3838
  return {
3716
3839
  success: true,
3717
3840
  itemId: itemId,
@@ -3990,8 +4113,210 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3990
4113
  return `(function() { return { error: 'Unknown selection action: ${input.action}' }; })();`;
3991
4114
  }
3992
4115
  }
4116
+ /**
4117
+ * @private Guard + call one PinePaper facade, uniformly.
4118
+ *
4119
+ * Every one of the 1.6.4 tools wraps a facade that a given editor build may
4120
+ * not have yet — the server ships independently of the app. A missing method
4121
+ * has to come back as a NAMED error, not a TypeError inside runGenerated:
4122
+ * this repo has shipped three emitters that called methods which never
4123
+ * existed (`imageTools.applyFilter`, `itemRegistry.entries`,
4124
+ * `itemRegistry.remove`) and each reported success or an opaque throw.
4125
+ */
4126
+ _facadeCall(method, args, label) {
4127
+ return `
4128
+ // ${label}
4129
+ (async function() {
4130
+ if (typeof app.${method} !== 'function') {
4131
+ return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
4132
+ }
4133
+ const r = await app.${method}(${args});
4134
+ // Facades report { ok } — normalise to { success } so every tool result reads
4135
+ // the same way, while keeping the original payload intact.
4136
+ if (r && typeof r === 'object' && 'ok' in r) {
4137
+ return { success: r.ok !== false, ...r };
4138
+ }
4139
+ return { success: true, result: r };
4140
+ })();`.trim();
4141
+ }
4142
+ generateBrandKit(input) {
4143
+ const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
4144
+ const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
4145
+ return this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
4146
+ }
4147
+ generateComponent(input) {
4148
+ const ids = JSON.stringify(input.itemIds || []);
4149
+ switch (input.action) {
4150
+ case 'define':
4151
+ // defineComponent takes ITEMS, not ids — resolve through the registry
4152
+ // here so the tool can speak the id space every other tool speaks.
4153
+ return `
4154
+ // Component: define
4155
+ (function() {
4156
+ if (typeof app.defineComponent !== 'function') {
4157
+ return { success: false, error: 'app.defineComponent() unavailable — update PinePaper Studio.' };
4158
+ }
4159
+ const items = ${ids}.map((id) => app.itemRegistry.get(id)?.item).filter(Boolean);
4160
+ if (!items.length) return { success: false, error: 'no live items for the given ids' };
4161
+ const r = app.defineComponent(items, ${JSON.stringify({ name: input.name })});
4162
+ return { success: r.ok !== false, ...r };
4163
+ })();`.trim();
4164
+ case 'list':
4165
+ return this._facadeCall('listComponents', '', 'Component: list');
4166
+ case 'instantiate':
4167
+ return this._facadeCall('instantiateComponent', `${JSON.stringify(input.componentId || '')}, ${JSON.stringify({
4168
+ ...(input.position ? { position: input.position } : {}),
4169
+ ...(input.overrides ? { overrides: input.overrides } : {}),
4170
+ })}`, 'Component: instantiate');
4171
+ case 'set_override':
4172
+ return this._facadeCall('setComponentOverride', `${JSON.stringify(input.instanceId || '')}, ${JSON.stringify(input.componentKey || '')}, ${JSON.stringify(input.prop || '')}, ${JSON.stringify(input.value ?? null)}`, 'Component: set override');
4173
+ case 'sync':
4174
+ return this._facadeCall('syncComponent', JSON.stringify(input.componentId || ''), 'Component: sync');
4175
+ case 'update_from_instance':
4176
+ return this._facadeCall('updateComponentFromInstance', JSON.stringify(input.instanceId || ''), 'Component: update from instance');
4177
+ case 'detach':
4178
+ return this._facadeCall('detachComponentInstance', JSON.stringify(input.instanceId || ''), 'Component: detach');
4179
+ }
4180
+ }
4181
+ generateArtboard(input) {
4182
+ switch (input.action) {
4183
+ case 'list_presets':
4184
+ return this._facadeCall('listArtboardPresets', '', 'Artboard: list presets');
4185
+ case 'set': {
4186
+ const target = input.preset
4187
+ ? JSON.stringify(input.preset)
4188
+ : JSON.stringify({ width: input.width, height: input.height });
4189
+ return this._facadeCall('setArtboard', target, 'Artboard: set');
4190
+ }
4191
+ case 'set_constraints':
4192
+ return this._facadeCall('setItemConstraints', `${JSON.stringify(input.itemId || '')}, ${JSON.stringify({
4193
+ ...(input.horizontal ? { horizontal: input.horizontal } : {}),
4194
+ ...(input.vertical ? { vertical: input.vertical } : {}),
4195
+ })}`, 'Artboard: set constraints');
4196
+ }
4197
+ }
4198
+ generateComment(input) {
4199
+ switch (input.action) {
4200
+ case 'add':
4201
+ return this._facadeCall('addComment', JSON.stringify({
4202
+ text: input.text,
4203
+ ...(input.author ? { author: input.author } : {}),
4204
+ ...(input.itemId ? { itemId: input.itemId } : {}),
4205
+ ...(input.x !== undefined ? { x: input.x } : {}),
4206
+ ...(input.y !== undefined ? { y: input.y } : {}),
4207
+ ...(input.time !== undefined ? { time: input.time } : {}),
4208
+ }), 'Comment: add');
4209
+ case 'list':
4210
+ return this._facadeCall('listComments', JSON.stringify({
4211
+ ...(input.time !== undefined ? { time: input.time } : {}),
4212
+ ...(input.includeResolved ? { includeResolved: true } : {}),
4213
+ }), 'Comment: list');
4214
+ case 'resolve':
4215
+ return this._facadeCall('resolveComment', `${JSON.stringify(input.id || '')}, ${input.resolved === false ? 'false' : 'true'}`, 'Comment: resolve');
4216
+ case 'delete':
4217
+ return this._facadeCall('deleteComment', JSON.stringify(input.id || ''), 'Comment: delete');
4218
+ }
4219
+ }
4220
+ generateProvenance(input) {
4221
+ const id = JSON.stringify(input.itemId);
4222
+ switch (input.action) {
4223
+ case 'get': return this._facadeCall('getItemProvenance', id, 'Provenance: get');
4224
+ case 'lineage': return this._facadeCall('getLineage', id, 'Provenance: lineage');
4225
+ case 'dependents': return this._facadeCall('getDependents', id, 'Provenance: dependents');
4226
+ case 'record':
4227
+ return this._facadeCall('recordLineage', `${id}, ${JSON.stringify(input.kind || 'derived')}, ${JSON.stringify(input.sourceRef || '')}, ${JSON.stringify(input.meta || {})}`, 'Provenance: record lineage');
4228
+ }
4229
+ }
4230
+ generateCompose(input) {
4231
+ switch (input.action) {
4232
+ case 'list_patterns':
4233
+ return this._facadeCall('listCollagePatterns', '', 'Compose: list patterns');
4234
+ case 'list_treatments':
4235
+ return this._facadeCall('listCameraTreatments', '', 'Compose: list treatments');
4236
+ case 'list_reveals':
4237
+ return this._facadeCall('listReveals', '', 'Compose: list reveals');
4238
+ case 'list_styles':
4239
+ return this._facadeCall('listStyles', '', 'Compose: list styles');
4240
+ case 'set_treatment':
4241
+ return this._facadeCall('setCollageTreatment', `${JSON.stringify(input.rootId || '')}, ${JSON.stringify(input.treatment || '')}, ${JSON.stringify({ ...(input.loop ? { loop: true } : {}) })}`, 'Compose: set treatment');
4242
+ case 'apply':
4243
+ default:
4244
+ // Forward the WHOLE surface. The first version forwarded only
4245
+ // treatment/camera/loop/craft — the temporal (reveal), style, text,
4246
+ // vector and audio halves of composeCollage were unreachable through
4247
+ // MCP, so agents converged on the four static patterns the description
4248
+ // happened to name. A capability gap reads as model bias from outside.
4249
+ return this._facadeCall('composeCollage', `${JSON.stringify(input.pattern || '')}, ${JSON.stringify(input.itemIds || [])}, ${JSON.stringify({
4250
+ ...(input.treatment ? { treatment: input.treatment } : {}),
4251
+ ...(input.applyCamera === false ? { applyCamera: false } : {}),
4252
+ ...(input.loop ? { loop: true } : {}),
4253
+ ...(input.craft ? { craft: input.craft } : {}),
4254
+ ...(input.reveal !== undefined ? { reveal: input.reveal } : {}),
4255
+ ...(input.revealOptions ? { revealOptions: input.revealOptions } : {}),
4256
+ ...(input.style ? { style: input.style } : {}),
4257
+ ...(input.text ? { text: input.text } : {}),
4258
+ ...(input.assets ? { assets: input.assets } : {}),
4259
+ ...(input.audio ? { audio: input.audio } : {}),
4260
+ ...(input.grid ? { grid: true } : {}),
4261
+ })}`, 'Compose: apply pattern');
4262
+ }
4263
+ }
4264
+ generateAudioBeats(input) {
4265
+ if (input.action === 'analyze') {
4266
+ return this._facadeCall('analyzeAudio', `${JSON.stringify(input.source || '')}, ${JSON.stringify({
4267
+ ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
4268
+ ...(input.minGap !== undefined ? { minGap: input.minGap } : {}),
4269
+ })}`, 'Audio: analyze beats');
4270
+ }
4271
+ return this._facadeCall('animateToBeat', `${JSON.stringify(input.itemId || '')}, ${JSON.stringify({
4272
+ ...(input.source ? { source: input.source } : {}),
4273
+ ...(input.beats ? { beats: input.beats } : {}),
4274
+ ...(input.grid ? { grid: true } : {}),
4275
+ ...(input.property ? { property: input.property } : {}),
4276
+ ...(input.base !== undefined ? { base: input.base } : {}),
4277
+ ...(input.accent !== undefined ? { accent: input.accent } : {}),
4278
+ ...(input.decay !== undefined ? { decay: input.decay } : {}),
4279
+ ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
4280
+ })}`, 'Audio: animate to beat');
4281
+ }
4282
+ generateTemplateParams(input) {
4283
+ if (input.action === 'get') {
4284
+ return this._facadeCall('getTemplateParams', JSON.stringify(input.templateId), 'Template params: get');
4285
+ }
4286
+ return this._facadeCall('applyTemplateWithParams', `${JSON.stringify(input.templateId)}, ${JSON.stringify(input.params || {})}`, 'Template params: apply');
4287
+ }
4288
+ generateSceneDiff(input) {
4289
+ if (input.action === 'version') {
4290
+ return this._facadeCall('diffAgainstVersion', JSON.stringify(input.versionId || ''), 'Scene diff: vs version');
4291
+ }
4292
+ return this._facadeCall('diffHistoryStates', `${input.indexA ?? 0}, ${input.indexB ?? 0}`, 'Scene diff: history');
4293
+ }
3993
4294
  generateTransform(input) {
3994
4295
  switch (input.action) {
4296
+ case 'fit': {
4297
+ const mode = input.mode || 'contain';
4298
+ return `
4299
+ // Fit item to the export frame (${mode})
4300
+ (function() {
4301
+ // Route through the agent facade, not app.fitToFrame directly: an agent that
4302
+ // just called upload_video holds a MEDIA id (vraster_…), which is a different
4303
+ // id space from canvas item ids. PinePaperAgent.fitToFrame resolves either.
4304
+ const A = (typeof window !== 'undefined') && window.PinePaperAgent;
4305
+ if (A && typeof A.fitToFrame === 'function') {
4306
+ const ok = A.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
4307
+ return ok
4308
+ ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
4309
+ : { success: false, error: 'Item not found or not fittable: ${input.itemId}' };
4310
+ }
4311
+ if (typeof app.fitToFrame !== 'function') {
4312
+ return { success: false, error: 'fitToFrame unavailable — update FxTool' };
4313
+ }
4314
+ const ok = app.fitToFrame(${JSON.stringify(input.itemId || '')}, '${mode}');
4315
+ return ok
4316
+ ? { success: true, action: 'fit', itemId: '${input.itemId}', mode: '${mode}' }
4317
+ : { success: false, error: 'Item not found: ${input.itemId}' };
4318
+ })();`.trim();
4319
+ }
3995
4320
  case 'nudge': {
3996
4321
  const dx = input.dx ?? 0;
3997
4322
  const dy = input.dy ?? 0;
@@ -4123,6 +4448,32 @@ ${resolve}
4123
4448
  })();`.trim();
4124
4449
  default:
4125
4450
  return `(function() { return { error: 'Unknown image_filter action: ${input.action}' }; })();`;
4451
+ case 'analyze_palette':
4452
+ return `
4453
+ // Analyze palette: the dominant colours, largest area first (GPU path)
4454
+ (async function() {
4455
+ if (typeof app.analyzePalette !== 'function') { return { success: false, error: 'app.analyzePalette unavailable — update FxTool to a palette-capable build' }; }
4456
+ try {
4457
+ const r = await app.analyzePalette(${JSON.stringify(input.itemId)}, ${JSON.stringify({ ...(input.maxSwatches !== undefined ? { maxSwatches: input.maxSwatches } : {}) })});
4458
+ // swatches: [{hex, share, …}] largest-first — feed hex values straight into
4459
+ // recolor_palette mapping or the paletteMap filter's swatches[].
4460
+ return { success: true, width: r.width, height: r.height, opaquePixels: r.opaquePixels, swatches: r.swatches };
4461
+ } catch (e) {
4462
+ return { success: false, error: e && e.message ? e.message : String(e) };
4463
+ }
4464
+ })();`.trim();
4465
+ case 'recolor_palette':
4466
+ return `
4467
+ // Recolor palette: swap colours, keep the shading (GPU path)
4468
+ (async function() {
4469
+ if (typeof app.recolorPalette !== 'function') { return { success: false, error: 'app.recolorPalette unavailable — update FxTool to a palette-capable build' }; }
4470
+ try {
4471
+ 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 } : {}) })});
4472
+ return { success: true, action: 'recolor_palette', itemId: ${JSON.stringify(input.itemId)} };
4473
+ } catch (e) {
4474
+ return { success: false, error: e && e.message ? e.message : String(e) };
4475
+ }
4476
+ })();`.trim();
4126
4477
  }
4127
4478
  }
4128
4479
  generateLasso(input) {
@@ -5228,6 +5579,338 @@ ${guard}
5228
5579
  if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
5229
5580
  const ok = A.setMediaClip(${JSON.stringify(input.id)}, ${input.inPoint}, ${input.outPoint});
5230
5581
  return { success: ok, action: 'set_clip', id: ${JSON.stringify(input.id)}, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} };
5582
+ })();`.trim();
5583
+ // ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
5584
+ // facades), not the agent media API, so they carry their own guards.
5585
+ // The facades snapshot history themselves; no saveState here.
5586
+ case 'set_time_remap': {
5587
+ const S = (v) => JSON.stringify(v);
5588
+ return `
5589
+ // Remap a clip's time — speed ramps, freeze frames, reverse (null clears)
5590
+ (function() {
5591
+ if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
5592
+ const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
5593
+ return r && r.ok ? { success: true, action: 'set_time_remap', points: r.points } : { success: false, error: (r && r.error) || 'remap failed' };
5594
+ })();`.trim();
5595
+ }
5596
+ case 'speed_ramp': {
5597
+ const S = (v) => JSON.stringify(v);
5598
+ return `
5599
+ // Speed ramp: consecutive {duration, speed} segments compiled to a remap curve
5600
+ (function() {
5601
+ if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
5602
+ const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
5603
+ return r && r.ok ? { success: true, action: 'speed_ramp', points: r.points } : { success: false, error: (r && r.error) || 'speed ramp failed' };
5604
+ })();`.trim();
5605
+ }
5606
+ case 'match_cut': {
5607
+ const S = (v) => JSON.stringify(v);
5608
+ const opts = S({
5609
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
5610
+ ...(input.label !== undefined ? { label: input.label } : {}),
5611
+ ...(input.at !== undefined ? { at: input.at } : {}),
5612
+ ...(input.settle !== undefined ? { settle: input.settle } : {}),
5613
+ ...(input.fade !== undefined ? { fade: input.fade } : {}),
5614
+ ...(input.consent !== undefined ? { consent: input.consent } : {}),
5615
+ });
5616
+ return `
5617
+ // Match cut: align the SUBJECT across the cut (on-device detection)
5618
+ (async function() {
5619
+ if (typeof app.matchCut !== 'function') { return { success: false, error: 'app.matchCut unavailable — update FxTool to a match-cut-capable build' }; }
5620
+ const r = await app.matchCut(${S(input.fromItemId)}, ${S(input.toItemId)}, ${opts});
5621
+ if (r && r.needsConsent) { return { success: false, needsConsent: true, cost: r.cost, error: 'Detection model download needs consent — re-call with consent: true' }; }
5622
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'match cut failed' }; }
5623
+ return { success: true, action: 'match_cut', aspectMismatch: r.aspectMismatch || false };
5624
+ })();`.trim();
5625
+ }
5626
+ case 'apply_track_matte': {
5627
+ const S = (v) => JSON.stringify(v);
5628
+ const opts = S({
5629
+ ...(input.channel !== undefined ? { channel: input.channel } : {}),
5630
+ ...(input.invert !== undefined ? { invert: input.invert } : {}),
5631
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
5632
+ ...(input.hideMatte !== undefined ? { hideMatte: input.hideMatte } : {}),
5633
+ ...(input.live !== undefined ? { live: input.live } : {}),
5634
+ });
5635
+ return `
5636
+ // Track matte: the matte item's ${input.channel || 'luma'} drives the target's alpha${input.live ? ' (LIVE — re-cuts as the matte animates)' : ''}
5637
+ (async function() {
5638
+ if (typeof app.applyTrackMatte !== 'function') { return { success: false, error: 'app.applyTrackMatte unavailable — update FxTool to a track-matte-capable build' }; }
5639
+ try {
5640
+ await app.applyTrackMatte(${S(input.id)}, ${S(input.matteItemId)}, ${opts});
5641
+ return { success: true, action: 'apply_track_matte', id: ${S(input.id)}, matte: ${S(input.matteItemId)}, live: ${S(!!input.live)} };
5642
+ } catch (e) {
5643
+ // The facade throws on a missing raster/matte rather than returning {ok:false}.
5644
+ return { success: false, error: e && e.message ? e.message : String(e) };
5645
+ }
5646
+ })();`.trim();
5647
+ }
5648
+ case 'stop_live_matte': {
5649
+ const S = (v) => JSON.stringify(v);
5650
+ return `
5651
+ // Stop a live matte, leaving the last cut in place
5652
+ (function() {
5653
+ if (typeof app.stopLiveMatte !== 'function') { return { success: false, error: 'app.stopLiveMatte unavailable — update FxTool' }; }
5654
+ const r = app.stopLiveMatte(${S(input.id)});
5655
+ return r && r.ok ? { success: true, action: 'stop_live_matte' } : { success: false, error: (r && r.error) || 'no live matte on that item' };
5656
+ })();`.trim();
5657
+ }
5658
+ }
5659
+ }
5660
+ /**
5661
+ * pinepaper_text_style — display styles + variable-font axes.
5662
+ * All three facades return their own {ok, …} envelopes; forwarded as-is.
5663
+ */
5664
+ generateTextStyle(input) {
5665
+ const S = (v) => JSON.stringify(v);
5666
+ switch (input.action) {
5667
+ case 'apply_style': {
5668
+ const opts = S({
5669
+ ...(input.palette !== undefined ? { palette: input.palette } : {}),
5670
+ ...(input.variant !== undefined ? { variant: input.variant } : {}),
5671
+ ...(input.content !== undefined ? { content: input.content } : {}),
5672
+ ...(input.fontFamily !== undefined ? { fontFamily: input.fontFamily } : {}),
5673
+ ...(input.fontSize !== undefined ? { fontSize: input.fontSize } : {}),
5674
+ });
5675
+ return `
5676
+ // Display text style: ${input.styleKey} — stacked-layer title, id adopted from the text
5677
+ (function() {
5678
+ if (typeof app.applyTextStyle !== 'function') { return { success: false, error: 'app.applyTextStyle unavailable — update FxTool to a text-styles build' }; }
5679
+ const r = app.applyTextStyle(${S(input.itemId)}, ${S(input.styleKey)}, ${opts});
5680
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'style failed' }; }
5681
+ // The styled group ADOPTS the text item's registry id — r.id is the SAME id
5682
+ // the caller passed, so their handle keeps working. Say so in the result.
5683
+ return { success: true, action: 'apply_style', id: r.id, style: r.style, layers: r.layers, palette: r.palette, bounds: r.bounds };
5684
+ })();`.trim();
5685
+ }
5686
+ case 'set_font_axes': {
5687
+ return `
5688
+ // Variable-font axes (standard trio: weight/width/slant — Canvas 2D has no custom axes)
5689
+ (function() {
5690
+ if (typeof app.setFontAxes !== 'function') { return { success: false, error: 'app.setFontAxes unavailable — update FxTool to a variable-font build' }; }
5691
+ const r = app.setFontAxes(${S(input.itemId)}, ${S(input.axes)});
5692
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'axes failed' }; }
5693
+ // rejected is the interesting half: an axis silently ignored is the failure
5694
+ // mode this surface exists to prevent.
5695
+ return { success: true, action: 'set_font_axes', applied: r.applied, rejected: r.rejected || [] };
5696
+ })();`.trim();
5697
+ }
5698
+ case 'list_styles': {
5699
+ return `
5700
+ // Text styles + palettes + font axes — the picker surface
5701
+ (function() {
5702
+ if (typeof app.listTextStyles !== 'function') { return { success: false, error: 'app.listTextStyles unavailable — update FxTool to a text-styles build' }; }
5703
+ return {
5704
+ success: true,
5705
+ styles: app.listTextStyles(),
5706
+ palettes: typeof app.listTextPalettes === 'function' ? app.listTextPalettes() : [],
5707
+ fontAxes: typeof app.listFontAxes === 'function' ? app.listFontAxes() : null,
5708
+ };
5709
+ })();`.trim();
5710
+ }
5711
+ }
5712
+ }
5713
+ /**
5714
+ * Shatter a raster into a grid of tiles. The group INHERITS the original's
5715
+ * registry id (relations/handles keep pointing at the thing in its place),
5716
+ * and the result is deliberately inert until something animates the pieces.
5717
+ */
5718
+ generateShatterImage(input) {
5719
+ const S = (v) => JSON.stringify(v);
5720
+ const opts = S({
5721
+ ...(input.pieces !== undefined ? { pieces: input.pieces } : {}),
5722
+ ...(input.rows !== undefined ? { rows: input.rows } : {}),
5723
+ ...(input.cols !== undefined ? { cols: input.cols } : {}),
5724
+ ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
5725
+ });
5726
+ return `
5727
+ // Shatter ${input.itemId} into tiles (inert until animated — that is the point)
5728
+ (function() {
5729
+ if (typeof app.shatterImage !== 'function') { return { success: false, error: 'app.shatterImage unavailable — update FxTool to a shatter-capable build' }; }
5730
+ const r = app.shatterImage(${S(input.itemId)}, ${opts});
5731
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'shatter failed' }; }
5732
+ // groupId === the original item's id (adopted). tiles/rows/cols tell the
5733
+ // caller what grid they actually got — 100 pieces of a 3:2 photo is 12x8=96.
5734
+ return { success: true, groupId: r.groupId, tiles: r.tiles, rows: r.rows, cols: r.cols };
5735
+ })();`.trim();
5736
+ }
5737
+ /**
5738
+ * Import a decomposed character (layer manifest + per-layer images) as a
5739
+ * role-bound Group. The facade is async and does its own heavy-module
5740
+ * loading (the cold-boot rigging-rules trap is handled inside it).
5741
+ */
5742
+ generateImportLayeredCharacter(input) {
5743
+ const S = (v) => JSON.stringify(v);
5744
+ const opts = S({
5745
+ ...(input.position !== undefined ? { position: input.position } : {}),
5746
+ ...(input.scale !== undefined ? { scale: input.scale } : {}),
5747
+ ...(input.name !== undefined ? { name: input.name } : {}),
5748
+ });
5749
+ return `
5750
+ // Import a layered character (decomposer output → role-bound parts; blink works out of the box)
5751
+ (async function() {
5752
+ if (typeof app.importLayeredCharacter !== 'function') { return { success: false, error: 'app.importLayeredCharacter unavailable — update FxTool to a layered-character build' }; }
5753
+ // The importer's contract is a Map (it calls images.get) — the wire format is
5754
+ // a plain object, so rebuild the Map here.
5755
+ const bundle = { info: ${S(input.info)}, images: new Map(Object.entries(${S(input.images)})) };
5756
+ const r = await app.importLayeredCharacter(bundle, ${opts});
5757
+ if (!r || !r.groupId) { return { success: false, error: (r && r.error) || 'layered character import failed' }; }
5758
+ // roles maps part roles → item ids; zero roles wired means the character
5759
+ // renders but will NOT animate — surface the count so that cannot hide.
5760
+ return { success: true, groupId: r.groupId, parts: (r.parts || []).length, roles: r.roles || {}, rolesWired: Object.keys(r.roles || {}).length, warnings: r.warnings || [] };
5761
+ })();`.trim();
5762
+ }
5763
+ /**
5764
+ * pinepaper_game — pathfinding + tilemaps via the PinePaper facades.
5765
+ * Both are pure data ops; the interesting glue is in the doc: paths feed
5766
+ * moves_along_path, collisionRects feed pinepaper_physics.
5767
+ */
5768
+ generateGame(input) {
5769
+ const S = (v) => JSON.stringify(v);
5770
+ switch (input.action) {
5771
+ case 'pathfind': {
5772
+ const opts = S({ ...(input.diagonal !== undefined ? { diagonal: input.diagonal } : {}) });
5773
+ return `
5774
+ // A* pathfind — waypoints for moves_along_path
5775
+ (async function() {
5776
+ if (typeof app.findPath !== 'function') { return { success: false, error: 'app.findPath unavailable — update FxTool to a games-capable build' }; }
5777
+ const r = await app.findPath(${S(input.grid)}, ${S(input.start)}, ${S(input.goal)}, ${opts});
5778
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'pathfind failed' }; }
5779
+ // path is world-space [{x,y}] — feed it DIRECTLY to pinepaper_add_relation
5780
+ // moves_along_path params.path, or to a keyframe track.
5781
+ return { success: true, path: r.path, waypoints: r.path.length };
5782
+ })();`.trim();
5783
+ }
5784
+ case 'create_tilemap': {
5785
+ const spec = S({
5786
+ cols: input.cols, rows: input.rows,
5787
+ ...(input.tileSize !== undefined ? { tileSize: input.tileSize } : {}),
5788
+ ...(input.origin !== undefined ? { origin: input.origin } : {}),
5789
+ ...(input.tileset !== undefined ? { tileset: input.tileset } : {}),
5790
+ ...(input.fills !== undefined ? { fills: input.fills } : {}),
5791
+ });
5792
+ return `
5793
+ // Tilemap: board data + pathfinding grid + merged collision rects
5794
+ (async function() {
5795
+ if (typeof app.createTilemap !== 'function') { return { success: false, error: 'app.createTilemap unavailable — update FxTool to a games-capable build' }; }
5796
+ const r = await app.createTilemap(${spec});
5797
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'tilemap failed' }; }
5798
+ // map: persist it (item.data / project document) — it is DATA, not items;
5799
+ // grid: pass straight back into pathfind; collisionRects: pinepaper_physics
5800
+ // bodies (greedy-merged, so a 60x1 wall is ONE rect, not 60).
5801
+ return { success: true, map: r.map, grid: r.grid, collisionRects: r.collisionRects, rectCount: r.collisionRects.length };
5802
+ })();`.trim();
5803
+ }
5804
+ }
5805
+ }
5806
+ /**
5807
+ * pinepaper_world3d — every action rides the PinePaper world facades. The
5808
+ * world is lazily created by createWorld3D; every other action guards on the
5809
+ * facade AND on a world existing, with the create hint in the error.
5810
+ */
5811
+ generateWorld3D(input) {
5812
+ const S = (v) => JSON.stringify(v);
5813
+ const needWorld = ` if (!app._world3d) { return { success: false, error: "no 3D world — call pinepaper_world3d create first" }; }`;
5814
+ switch (input.action) {
5815
+ case 'create': {
5816
+ const opts = S({ ...(input.character !== undefined ? { character: input.character } : {}) });
5817
+ return `
5818
+ // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
5819
+ (async function() {
5820
+ if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
5821
+ await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
5822
+ // describe() is the world's own parameter schema — return the preset list +
5823
+ // top-level keys so the agent knows what configure can touch, without the
5824
+ // full multi-KB schema on every create.
5825
+ const d = typeof app.describeWorld3D === 'function' ? await app.describeWorld3D() : null;
5826
+ return { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: d ? Object.keys(d.params || d) : [] };
5827
+ })();`.trim();
5828
+ }
5829
+ case 'describe':
5830
+ return `
5831
+ // World3D: the engine's own parameter schema — types, ranges, descriptions
5832
+ (async function() {
5833
+ if (typeof app.describeWorld3D !== 'function') { return { success: false, error: 'app.describeWorld3D unavailable — update FxTool' }; }
5834
+ return { success: true, schema: await app.describeWorld3D() };
5835
+ })();`.trim();
5836
+ case 'configure':
5837
+ return `
5838
+ // World3D: live configure (deep-merged, schema-validated)
5839
+ (function() {
5840
+ if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
5841
+ ${needWorld}
5842
+ const r = app.configureWorld3D(${S(input.patch)});
5843
+ // The validator names the right key on a wrong one — forward it verbatim.
5844
+ return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
5845
+ })();`.trim();
5846
+ case 'add_actor': {
5847
+ const actor = S({
5848
+ ...(input.actorId !== undefined ? { id: input.actorId } : {}),
5849
+ ...(input.x !== undefined ? { x: input.x } : {}),
5850
+ ...(input.z !== undefined ? { z: input.z } : {}),
5851
+ ...(input.height !== undefined ? { height: input.height } : {}),
5852
+ ...(input.sprite !== undefined ? { sprite: input.sprite } : {}),
5853
+ ...(input.live !== undefined ? { live: input.live } : {}),
5854
+ });
5855
+ return `
5856
+ // World3D: put an actor on the stage${input.live ? ' (live sprite — a rigged character PERFORMS, not a photograph of itself)' : ''}
5857
+ (function() {
5858
+ if (typeof app.addWorldActor !== 'function') { return { success: false, error: 'app.addWorldActor unavailable — update FxTool' }; }
5859
+ ${needWorld}
5860
+ const id = app.addWorldActor(${actor});
5861
+ return id ? { success: true, actorId: id } : { success: false, error: 'actor not added — is the sprite id a canvas item?' };
5862
+ })();`.trim();
5863
+ }
5864
+ case 'remove_actor':
5865
+ return `
5866
+ // World3D: remove actor
5867
+ (function() {
5868
+ ${needWorld}
5869
+ return { success: !!app.removeWorldActor(${S(input.actorId)}), actorId: ${S(input.actorId)} };
5870
+ })();`.trim();
5871
+ case 'list_actors':
5872
+ return `
5873
+ // World3D: the addressable stage
5874
+ (function() {
5875
+ ${needWorld}
5876
+ return { success: true, actors: app.listWorldActors() };
5877
+ })();`.trim();
5878
+ case 'set_actor_pose':
5879
+ return `
5880
+ // World3D: pose an actor — the setter a timeline, sequencer or agent drives
5881
+ (function() {
5882
+ ${needWorld}
5883
+ return { success: !!app.setWorldActorPose(${S(input.actorId)}, ${S(input.pose)}), actorId: ${S(input.actorId)} };
5884
+ })();`.trim();
5885
+ case 'set_camera':
5886
+ return `
5887
+ // World3D: direct the camera (follow | fixed | orbit)
5888
+ (function() {
5889
+ ${needWorld}
5890
+ const r = app.setWorldCamera(${S(input.camera)});
5891
+ return r === false ? { success: false, error: 'camera config rejected' } : { success: true };
5892
+ })();`.trim();
5893
+ case 'add_object':
5894
+ return `
5895
+ // World3D: place an object (y defaults to sitting on the terrain)
5896
+ (function() {
5897
+ ${needWorld}
5898
+ const id = app.addWorldObject(${S(input.object)});
5899
+ return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
5900
+ })();`.trim();
5901
+ case 'remove_object':
5902
+ return `
5903
+ // World3D: remove object
5904
+ (function() {
5905
+ ${needWorld}
5906
+ return { success: !!app.removeWorldObject(${S(input.objectId)}), objectId: ${S(input.objectId)} };
5907
+ })();`.trim();
5908
+ case 'remove_world':
5909
+ return `
5910
+ // World3D: tear the world down — Paper's canvas is untouched (it was always its own layer)
5911
+ (function() {
5912
+ if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
5913
+ return { success: !!app.removeWorld3D() };
5231
5914
  })();`.trim();
5232
5915
  }
5233
5916
  }
@@ -5345,6 +6028,61 @@ ${guard}
5345
6028
  return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
5346
6029
  if (app.historyManager) app.historyManager.saveState();
5347
6030
  return { success: !!shapeKeyId, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
6031
+ // ── Mocap / rig import — these live on `app` (PinePaper facades), not
6032
+ // riggingSystem, and they are async, so they get their own async IIFE
6033
+ // instead of the sync `wrap` above. Each returns the facade's own result
6034
+ // object: it already carries {ok, error, warnings…} and inventing a
6035
+ // second envelope here would just hide the interesting fields.
6036
+ case 'import_bvh': {
6037
+ const opts = S({
6038
+ ...(input.view !== undefined ? { view: input.view } : {}),
6039
+ ...(input.fps !== undefined ? { fps: input.fps } : {}),
6040
+ ...(input.height !== undefined ? { height: input.height } : {}),
6041
+ ...(input.rootPosition !== undefined ? { position: input.rootPosition } : {}),
6042
+ ...(input.name !== undefined ? { name: input.name } : {}),
6043
+ });
6044
+ return `
6045
+ // Rigging: import BVH mocap as a new rig (stick figure included)
6046
+ (async function() {
6047
+ if (typeof app.importBVH !== 'function') { return { success: false, error: 'app.importBVH unavailable — update FxTool to a BVH-capable build' }; }
6048
+ const r = await app.importBVH(${S(input.bvhText ?? '')}, ${opts});
6049
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'BVH import failed' }; }
6050
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6051
+ return { success: true, action: 'import_bvh', skeletonId: r.skeletonId, poses: r.poses, duration: r.duration, warnings: r.warnings || [] };
6052
+ })();`.trim();
6053
+ }
6054
+ case 'retarget_bvh': {
6055
+ const opts = S({
6056
+ ...(input.fps !== undefined ? { fps: input.fps } : {}),
6057
+ ...(input.name !== undefined ? { name: input.name } : {}),
6058
+ });
6059
+ return `
6060
+ // Rigging: retarget a BVH clip onto an EXISTING rig (matched by bone name)
6061
+ (async function() {
6062
+ if (typeof app.retargetBVH !== 'function') { return { success: false, error: 'app.retargetBVH unavailable — update FxTool to a BVH-capable build' }; }
6063
+ const r = await app.retargetBVH(${S(input.bvhText ?? '')}, ${S(input.skeletonId)}, ${opts});
6064
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'BVH retarget failed' }; }
6065
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6066
+ // matched/unmatched matter: a retarget that matched 2 of 15 bones "succeeded"
6067
+ // and looks broken — the caller needs the numbers to know which happened.
6068
+ 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 || [] };
6069
+ })();`.trim();
6070
+ }
6071
+ case 'import_spine': {
6072
+ const opts = S({
6073
+ ...(input.rootPosition !== undefined ? { position: input.rootPosition } : {}),
6074
+ ...(input.name !== undefined ? { name: input.name } : {}),
6075
+ });
6076
+ return `
6077
+ // Rigging: import a Spine JSON export (bones, poses, attachment placeholders)
6078
+ (async function() {
6079
+ if (typeof app.importSpine !== 'function') { return { success: false, error: 'app.importSpine unavailable — update FxTool to a Spine-capable build' }; }
6080
+ const r = await app.importSpine(${S(input.spineJson ?? '')}, ${opts});
6081
+ if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'Spine import failed' }; }
6082
+ // (facade snapshots history itself — a second saveState here would cost an extra undo step)
6083
+ 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 || [] };
6084
+ })();`.trim();
6085
+ }
5348
6086
  }
5349
6087
  }
5350
6088
  /**