@pinepaper.studio/mcp-server 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.de.md +71 -0
  2. package/README.es.md +71 -0
  3. package/README.fr.md +71 -0
  4. package/README.hi.md +71 -0
  5. package/README.ja.md +71 -0
  6. package/README.ko.md +71 -0
  7. package/README.md +513 -272
  8. package/README.pt-BR.md +71 -0
  9. package/README.zh-CN.md +71 -0
  10. package/assets/easing-splashes.svg +152 -0
  11. package/assets/equation-path.svg +77 -0
  12. package/assets/hero.svg +126 -0
  13. package/assets/live-badge.svg +62 -0
  14. package/assets/poster-sunburst.svg +106 -0
  15. package/dist/__tests__/fixtures/mock-relations.d.ts +48 -0
  16. package/dist/__tests__/fixtures/mock-relations.d.ts.map +1 -1
  17. package/dist/__tests__/fixtures/mock-relations.js +31 -0
  18. package/dist/__tests__/fixtures/mock-relations.js.map +1 -1
  19. package/dist/agent/types.d.ts +6 -6
  20. package/dist/browser/puppeteer-controller.d.ts +34 -0
  21. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  22. package/dist/browser/puppeteer-controller.js +116 -5
  23. package/dist/browser/puppeteer-controller.js.map +1 -1
  24. package/dist/cli.js +2 -1
  25. package/dist/cli.js.map +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +35 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/ontology/index.d.ts +2 -0
  30. package/dist/ontology/index.d.ts.map +1 -1
  31. package/dist/ontology/index.js +2 -0
  32. package/dist/ontology/index.js.map +1 -1
  33. package/dist/ontology/ontology-compiler.d.ts +78 -0
  34. package/dist/ontology/ontology-compiler.d.ts.map +1 -0
  35. package/dist/ontology/ontology-compiler.js +235 -0
  36. package/dist/ontology/ontology-compiler.js.map +1 -0
  37. package/dist/ontology/ontology-validator.d.ts +103 -0
  38. package/dist/ontology/ontology-validator.d.ts.map +1 -0
  39. package/dist/ontology/ontology-validator.js +573 -0
  40. package/dist/ontology/ontology-validator.js.map +1 -0
  41. package/dist/ontology/types.d.ts +1 -0
  42. package/dist/ontology/types.d.ts.map +1 -1
  43. package/dist/ontology/validator.d.ts +6 -0
  44. package/dist/ontology/validator.d.ts.map +1 -1
  45. package/dist/ontology/validator.js +34 -5
  46. package/dist/ontology/validator.js.map +1 -1
  47. package/dist/ontology/vocabulary.d.ts.map +1 -1
  48. package/dist/ontology/vocabulary.js +91 -3
  49. package/dist/ontology/vocabulary.js.map +1 -1
  50. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  51. package/dist/tools/compact-descriptions.js +0 -1
  52. package/dist/tools/compact-descriptions.js.map +1 -1
  53. package/dist/tools/definitions.d.ts +1 -1
  54. package/dist/tools/definitions.d.ts.map +1 -1
  55. package/dist/tools/definitions.js +997 -573
  56. package/dist/tools/definitions.js.map +1 -1
  57. package/dist/tools/handlers/camera.d.ts.map +1 -1
  58. package/dist/tools/handlers/camera.js +7 -1
  59. package/dist/tools/handlers/camera.js.map +1 -1
  60. package/dist/tools/handlers/maps.d.ts.map +1 -1
  61. package/dist/tools/handlers/maps.js +54 -1
  62. package/dist/tools/handlers/maps.js.map +1 -1
  63. package/dist/tools/handlers/ontology.d.ts.map +1 -1
  64. package/dist/tools/handlers/ontology.js +15 -3
  65. package/dist/tools/handlers/ontology.js.map +1 -1
  66. package/dist/tools/handlers.d.ts.map +1 -1
  67. package/dist/tools/handlers.js +160 -113
  68. package/dist/tools/handlers.js.map +1 -1
  69. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  70. package/dist/tools/minimal-descriptions.js +23 -16
  71. package/dist/tools/minimal-descriptions.js.map +1 -1
  72. package/dist/tools/toolkits.d.ts.map +1 -1
  73. package/dist/tools/toolkits.js +28 -22
  74. package/dist/tools/toolkits.js.map +1 -1
  75. package/dist/types/code-generator.d.ts +190 -36
  76. package/dist/types/code-generator.d.ts.map +1 -1
  77. package/dist/types/code-generator.js +1216 -571
  78. package/dist/types/code-generator.js.map +1 -1
  79. package/dist/types/schemas.d.ts +2826 -829
  80. package/dist/types/schemas.d.ts.map +1 -1
  81. package/dist/types/schemas.js +595 -79
  82. package/dist/types/schemas.js.map +1 -1
  83. package/dist/version.d.ts +11 -0
  84. package/dist/version.d.ts.map +1 -0
  85. package/dist/version.js +18 -0
  86. package/dist/version.js.map +1 -0
  87. package/manifest.json +299 -71
  88. package/package.json +14 -3
@@ -10,20 +10,19 @@ import { CreateItemInputSchema, ModifyItemInputSchema, AddRelationInputSchema, A
10
10
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
11
11
  // Agent flow mode types
12
12
  AgentStartJobInputSchema, AgentEndJobInputSchema, AgentResetInputSchema, AgentBatchExecuteInputSchema, AgentExportInputSchema, AgentAnalyzeInputSchema,
13
- // Interactive/Trigger types
14
- AddTriggerInputSchema, RemoveTriggerInputSchema, QueryTriggersInputSchema,
15
- // Quiz types
16
- CreateQuizInputSchema, GetQuizStateInputSchema, ResetQuizInputSchema,
17
13
  // Letter collage types
18
14
  CreateLetterCollageInputSchema, AnimateLetterCollageInputSchema,
19
15
  // Map types
20
16
  LoadMapInputSchema, HighlightRegionsInputSchema, UnhighlightRegionsInputSchema, ApplyDataColorsInputSchema, AddMarkerInputSchema, AddMapLabelsInputSchema, PanMapInputSchema, ZoomMapInputSchema, ImportCustomMapInputSchema, GetRegionAtPointInputSchema,
21
17
  // Map animation/CSV types
22
18
  AnimateMapRegionsInputSchema, AnimateMapWaveInputSchema, StopMapAnimationsInputSchema, ExportMapRegionCSVInputSchema, ImportMapRegionCSVInputSchema, SelectMapRegionsInputSchema, DeselectMapRegionsInputSchema,
19
+ // Globe + world tour types
20
+ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTourInputSchema, StopWorldTourInputSchema, PinToGlobeInputSchema, TourItemInputSchema,
23
21
  // Custom relation/code types
24
22
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
25
23
  // Mask types
26
24
  ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, } from './schemas.js';
25
+ import { OntologyCompiler } from '../ontology/ontology-compiler.js';
27
26
  // =============================================================================
28
27
  // HELPER FUNCTIONS
29
28
  // =============================================================================
@@ -277,7 +276,9 @@ const relations = results.map(r => ({
277
276
  /**
278
277
  * Template for simple animation
279
278
  */
280
- function generateAnimateCode(itemId, animationType, speed) {
279
+ function generateAnimateCode(itemId, animationType, speed, intensity, delay) {
280
+ const extra = (intensity !== undefined ? `,\n animationIntensity: ${intensity}` : '') +
281
+ (delay !== undefined ? `,\n animationDelay: ${delay}` : '');
281
282
  return `
282
283
  // Apply ${animationType} animation to ${itemId}
283
284
  const item = app.getItemById('${itemId}');
@@ -286,17 +287,33 @@ if (!item) {
286
287
  }
287
288
  app.animate(item, {
288
289
  animationType: '${animationType}',
289
- animationSpeed: ${speed}
290
+ animationSpeed: ${speed}${extra}
290
291
  });
291
292
 
292
293
  ({ success: true, itemId: '${itemId}', animationType: '${animationType}' });
293
294
  `.trim();
294
295
  }
296
+ /**
297
+ * PinePaper's keyframe interpolator only tweens `position` when it is an [x, y]
298
+ * ARRAY. Callers (and other agents) routinely pass a { x, y } object, which is
299
+ * silently ignored — the item then stays frozen for the whole animation. Accept
300
+ * both shapes by normalizing { x, y } -> [x, y] before the keyframes are emitted.
301
+ */
302
+ function normalizeKeyframePositions(keyframes) {
303
+ return (keyframes || []).map((kf) => {
304
+ const props = kf?.properties;
305
+ const p = props?.position;
306
+ if (p && !Array.isArray(p) && typeof p === 'object' && 'x' in p && 'y' in p) {
307
+ return { ...kf, properties: { ...props, position: [p.x, p.y] } };
308
+ }
309
+ return kf;
310
+ });
311
+ }
295
312
  /**
296
313
  * Template for keyframe animation
297
314
  */
298
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint) {
299
- const keyframesJson = JSON.stringify(keyframes, null, 2);
315
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
316
+ const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
300
317
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
301
318
  const opts = { duration: calculatedDuration, loop };
302
319
  if (timeOffset !== undefined)
@@ -305,6 +322,8 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
305
322
  opts.clipInPoint = clipInPoint;
306
323
  if (clipOutPoint !== undefined)
307
324
  opts.clipOutPoint = clipOutPoint;
325
+ if (timeUnits !== undefined)
326
+ opts.timeUnits = timeUnits;
308
327
  return `
309
328
  // Apply keyframe animation to ${itemId}
310
329
  app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
@@ -315,12 +334,15 @@ app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
315
334
  /**
316
335
  * Template for executing generators
317
336
  */
318
- function generateExecuteGeneratorCode(generatorName, params) {
337
+ function generateExecuteGeneratorCode(generatorName, params, region) {
338
+ // FxTool signature: app.executeGenerator(name, params, options).
339
+ // region lives in options (added in FxTool c81781c).
340
+ const options = region ? { region } : {};
319
341
  return `
320
- // Execute ${generatorName} generator
342
+ // Execute ${generatorName} generator${region ? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})` : ''}
321
343
  (async function() {
322
- await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)});
323
- return { success: true, generator: '${generatorName}' };
344
+ await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)}, ${JSON.stringify(options)});
345
+ return { success: true, generator: '${generatorName}'${region ? ', region: ' + JSON.stringify(region) : ''} };
324
346
  })();
325
347
  `.trim();
326
348
  }
@@ -389,12 +411,12 @@ const items = filtered.map(entry => ({
389
411
  /**
390
412
  * Template for timeline control
391
413
  */
392
- function generatePlayTimelineCode(action, duration, loop, time) {
414
+ function generatePlayTimelineCode(action, duration, loop, time, deterministic) {
393
415
  switch (action) {
394
416
  case 'play':
395
417
  return `
396
418
  // Play keyframe timeline
397
- app.playKeyframeTimeline(${duration || 5}, ${loop ?? false});
419
+ app.playKeyframeTimeline(${duration || 5}, ${loop ?? false}, { timeUnits: 'seconds' });
398
420
  ({ success: true, action: 'play', duration: ${duration || 5}, loop: ${loop ?? false} });
399
421
  `.trim();
400
422
  case 'pause':
@@ -410,6 +432,18 @@ app.stopKeyframeTimeline();
410
432
  ({ success: true, action: 'stop' });
411
433
  `.trim();
412
434
  case 'seek':
435
+ if (deterministic) {
436
+ return `
437
+ // Deterministic seek — evaluate the whole scene at the exact time (keyframes + relations + generators)
438
+ (function() {
439
+ const t = ${time || 0};
440
+ if (typeof app.sceneAt === 'function') { app.sceneAt(t); return { success: true, action: 'seek', time: t, deterministic: true }; }
441
+ // Fallback for older FxTool builds without sceneAt
442
+ app.setPlaybackTime(t);
443
+ return { success: true, action: 'seek', time: t, deterministic: false, note: 'app.sceneAt unavailable — used setPlaybackTime (keyframe state only); update FxTool for deterministic relations/generators' };
444
+ })();
445
+ `.trim();
446
+ }
413
447
  return `
414
448
  // Seek to time
415
449
  app.setPlaybackTime(${time || 0});
@@ -456,35 +490,29 @@ const size = app.getCanvasSize();
456
490
  */
457
491
  function generateClearCanvasCode() {
458
492
  return `
459
- // Clear all items from canvas
460
- const entries = app.itemRegistry.getAll();
461
- const itemIds = entries.map(entry => entry.id || entry.itemId);
462
- let removedCount = 0;
463
-
464
- for (const itemId of itemIds) {
465
- try {
466
- const item = app.getItemById(itemId);
467
- if (item) {
468
- item.remove();
469
- app.itemRegistry.remove(itemId);
470
- removedCount++;
471
- }
472
- } catch (e) {
473
- console.warn('Failed to remove item:', itemId, e);
474
- }
475
- }
476
-
477
- // Also clear any relations
478
- if (app.clearAllRelations) {
479
- app.clearAllRelations();
493
+ // Clear all items from canvas. Prefer app.clearCanvas() — the canonical
494
+ // reset that also tears down relations, the (virtual) camera animation,
495
+ // generators, effects and background. The old manual loop here was broken:
496
+ // it keyed item lookups on entry.id/entry.itemId (the registry getAll()
497
+ // entries don't expose those — getItemById wants the registry id), so it
498
+ // removed 0 items, and it called the non-existent app.clearAllRelations.
499
+ const before = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
500
+
501
+ if (typeof app.clearCanvas === 'function') {
502
+ app.clearCanvas();
503
+ } else {
504
+ // Fallback for older builds without clearCanvas().
505
+ if (app.textItemGroup) app.textItemGroup.removeChildren();
506
+ if (app.patternGroup) app.patternGroup.removeChildren();
507
+ if (app.itemRegistry) app.itemRegistry.clear();
508
+ if (app.relationRegistry) app.relationRegistry.clear();
480
509
  }
481
510
 
482
- app.historyManager.saveState();
511
+ if (app.historyManager) app.historyManager.saveState();
483
512
 
484
- // Verify canvas is empty
485
- const remaining = app.itemRegistry.getAll().length;
513
+ const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
486
514
 
487
- ({ success: true, removedCount, remainingItems: remaining });
515
+ ({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining });
488
516
  `.trim();
489
517
  }
490
518
  /**
@@ -549,6 +577,82 @@ const formatted = generators.map(g => ({
549
577
  ({ generators: formatted });
550
578
  `.trim();
551
579
  }
580
+ /**
581
+ * True when app.create() derives this item's geometry from explicit coordinates
582
+ * and therefore ignores params.position:
583
+ * - path built from `segments` or `pathData`
584
+ * - line built from `from`/`to`
585
+ * - arc built from `from`/`through`/`to`
586
+ * Without those coordinate props these types fall back to the position-derived
587
+ * point, so create() already places them correctly and we must not interfere.
588
+ */
589
+ export function isCoordinateBuilt(itemType, props) {
590
+ switch (itemType) {
591
+ case 'path':
592
+ return props.segments !== undefined || props.pathData !== undefined;
593
+ case 'line':
594
+ return props.from !== undefined || props.to !== undefined;
595
+ case 'arc':
596
+ return props.from !== undefined || props.through !== undefined || props.to !== undefined;
597
+ default:
598
+ return false;
599
+ }
600
+ }
601
+ /**
602
+ * app.addAnimation() REPLACES data.keyframes wholesale, so two
603
+ * keyframe_animate ops targeting one item meant the first track was silently
604
+ * discarded — the batch still reported success while (e.g.) a rotation channel
605
+ * vanished because a later op set a colour channel on the same item.
606
+ *
607
+ * Plan a merge: fold every duplicate target's keyframes into its FIRST op
608
+ * (keeping author order), union by timestamp with later ops winning on a
609
+ * per-property conflict. Op slots are preserved so `results` stays 1:1 with
610
+ * `operations` and `$N` indices are untouched; the folded slots emit an
611
+ * explicit `{ merged: true }` marker so the merge is visible to the caller
612
+ * rather than being another silent rewrite.
613
+ */
614
+ export function planKeyframeMerges(operations) {
615
+ const byTarget = new Map();
616
+ operations.forEach((op, i) => {
617
+ if (op.type !== 'keyframe_animate' || !op.itemId)
618
+ return;
619
+ const list = byTarget.get(op.itemId) || [];
620
+ list.push(i);
621
+ byTarget.set(op.itemId, list);
622
+ });
623
+ const plan = { merged: new Map(), foldedInto: new Map() };
624
+ for (const indices of byTarget.values()) {
625
+ if (indices.length < 2)
626
+ continue;
627
+ const first = indices[0];
628
+ // Union keyframes by time; later ops shallow-merge over earlier ones.
629
+ const byTime = new Map();
630
+ let duration = 0;
631
+ let loop = false;
632
+ for (const i of indices) {
633
+ const op = operations[i];
634
+ for (const kf of op.keyframes || []) {
635
+ const prev = byTime.get(kf.time);
636
+ byTime.set(kf.time, {
637
+ time: kf.time,
638
+ properties: { ...(prev?.properties || {}), ...(kf.properties || {}) },
639
+ ...((kf.easing ?? prev?.easing) !== undefined ? { easing: kf.easing ?? prev?.easing } : {}),
640
+ });
641
+ }
642
+ const opMax = op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 0;
643
+ duration = Math.max(duration, op.duration || opMax);
644
+ loop = loop || op.loop === true;
645
+ if (i !== first)
646
+ plan.foldedInto.set(i, first);
647
+ }
648
+ plan.merged.set(first, {
649
+ keyframes: [...byTime.values()].sort((a, b) => a.time - b.time),
650
+ duration: duration || 5,
651
+ loop,
652
+ });
653
+ }
654
+ return plan;
655
+ }
552
656
  /**
553
657
  * Template for batch create - creates multiple items with single history save
554
658
  */
@@ -632,6 +736,10 @@ export class PinePaperCodeGenerator {
632
736
  properties.animationType = validated.animationType;
633
737
  if (validated.animationSpeed !== undefined)
634
738
  properties.animationSpeed = validated.animationSpeed;
739
+ if (validated.animationIntensity !== undefined)
740
+ properties.animationIntensity = validated.animationIntensity;
741
+ if (validated.animationDelay !== undefined)
742
+ properties.animationDelay = validated.animationDelay;
635
743
  if (validated.keyframes !== undefined)
636
744
  properties.keyframes = validated.keyframes;
637
745
  return generateCreateItemCode(validated.itemType, validated.position, properties);
@@ -673,21 +781,21 @@ export class PinePaperCodeGenerator {
673
781
  */
674
782
  generateAnimate(input) {
675
783
  const validated = AnimateItemInputSchema.parse(input);
676
- return generateAnimateCode(validated.itemId, validated.animationType, validated.speed);
784
+ return generateAnimateCode(validated.itemId, validated.animationType, validated.speed, validated.intensity, validated.delay);
677
785
  }
678
786
  /**
679
787
  * Generate code for keyframe animation
680
788
  */
681
789
  generateKeyframeAnimate(input) {
682
790
  const validated = KeyframeAnimateInputSchema.parse(input);
683
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint);
791
+ return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
684
792
  }
685
793
  /**
686
794
  * Generate code for executing a generator
687
795
  */
688
796
  generateExecuteGenerator(input) {
689
797
  const validated = ExecuteGeneratorInputSchema.parse(input);
690
- return generateExecuteGeneratorCode(validated.generatorName, validated.params);
798
+ return generateExecuteGeneratorCode(validated.generatorName, validated.params, validated.region);
691
799
  }
692
800
  /**
693
801
  * Generate code for applying an effect
@@ -705,8 +813,8 @@ export class PinePaperCodeGenerator {
705
813
  /**
706
814
  * Generate code for timeline control
707
815
  */
708
- generatePlayTimeline(action, duration, loop, time) {
709
- return generatePlayTimelineCode(action, duration, loop, time);
816
+ generatePlayTimeline(action, duration, loop, time, deterministic) {
817
+ return generatePlayTimelineCode(action, duration, loop, time, deterministic);
710
818
  }
711
819
  /**
712
820
  * Generate code for setting background color
@@ -864,6 +972,28 @@ throw new Error('Either svgString or url must be provided');
864
972
  viewport: ctx.viewport,
865
973
  };
866
974
  })();
975
+ `.trim();
976
+ }
977
+ /**
978
+ * Generate code for semantic validation (FxTool OntologyValidator) — runs in the
979
+ * browser against the live scene and returns structured diagnostics.
980
+ */
981
+ generateValidate(input) {
982
+ const mode = input.mode || 'scene';
983
+ const opJson = JSON.stringify(input.op ?? null);
984
+ return `
985
+ // Semantic validation — structured diagnostics
986
+ (function() {
987
+ if (typeof app.validateScene !== 'function' || typeof app.validateOp !== 'function') {
988
+ return {
989
+ success: false,
990
+ error: 'app.validateScene/validateOp not available — requires FxTool with OntologyValidator v1 (commit 7f151f4 or later).',
991
+ };
992
+ }
993
+ var mode = ${JSON.stringify(mode)};
994
+ var result = (mode === 'op') ? app.validateOp(${opJson}) : app.validateScene();
995
+ return { success: true, mode: mode, ok: result.ok, diagnostics: result.diagnostics };
996
+ })();
867
997
  `.trim();
868
998
  }
869
999
  /**
@@ -1178,13 +1308,9 @@ if (typeof app.generators !== 'undefined' && app.generators['${backgroundGenerat
1178
1308
  const sourceId = nameToId['${source}'];
1179
1309
  const targetId = nameToId['${target}'];
1180
1310
  if (sourceId && targetId) {
1181
- const sourceItem = app.itemRegistry.get(sourceId);
1182
- const targetItem = app.itemRegistry.get(targetId);
1183
- if (sourceItem && targetItem) {
1184
- const params = ${paramsStr};
1185
- app.relationManager.addRelation(sourceItem, targetItem, '${type}', params);
1186
- results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
1187
- }
1311
+ const params = ${paramsStr};
1312
+ app.addRelation(sourceId, targetId, '${type}', params);
1313
+ results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
1188
1314
  }
1189
1315
  })();
1190
1316
  `);
@@ -1601,14 +1727,7 @@ throw new Error('Unknown diagram mode action: ${action}');
1601
1727
  if (item.shadowColor || item.shadowBlur) analysis.hasShadows = true;
1602
1728
  });
1603
1729
 
1604
- // Check animation manager for registered animations
1605
- if (app.animationManager) {
1606
- const animations = app.animationManager.getAll ? app.animationManager.getAll() : (app.animationManager.animations || []);
1607
- if (animations.length > 0) {
1608
- analysis.hasAnimations = true;
1609
- animationSet.add('managed');
1610
- }
1611
- }
1730
+ // FxTool has no animationManager — animations are detected via item.data.animationType walk above
1612
1731
  // Check timeline for active animations
1613
1732
  if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
1614
1733
  analysis.hasAnimations = true;
@@ -1733,6 +1852,9 @@ throw new Error('Unknown diagram mode action: ${action}');
1733
1852
  const validated = AgentBatchExecuteInputSchema.parse(input);
1734
1853
  const { operations, atomic } = validated;
1735
1854
  const isAtomic = atomic !== false;
1855
+ // Fold duplicate keyframe_animate targets so a later op can't silently
1856
+ // clobber an earlier track (app.addAnimation replaces, never merges).
1857
+ const mergePlan = planKeyframeMerges(operations);
1736
1858
  let code = `
1737
1859
  // Batch execute ${operations.length} operations
1738
1860
  (async function() {
@@ -1743,7 +1865,17 @@ throw new Error('Unknown diagram mode action: ${action}');
1743
1865
  try {
1744
1866
  `;
1745
1867
  operations.forEach((op, index) => {
1746
- const opCode = this.generateBatchOperationCode(op, index);
1868
+ const opCode = this.generateBatchOperationCode(op, index, mergePlan);
1869
+ // $N variable references are documented as "items CREATED in earlier
1870
+ // operations" — only `create` results may extend the itemIds array.
1871
+ // animate/keyframe_animate/modify/delete also return { itemId } (the
1872
+ // TARGET's id), and unconditionally pushing those shifted every later
1873
+ // $N reference: a batch of [create ×5, keyframe_animate ×5, create ×5,
1874
+ // keyframe_animate ×5] silently re-animated the FIRST five items and
1875
+ // left the second five without keyframes (mis-oriented/mis-animated
1876
+ // vehicles, 2026-07-30). `group` already follows this rule by
1877
+ // returning groupId instead of itemId.
1878
+ const claimsItemSlot = op.type === 'create';
1747
1879
  code += `
1748
1880
  // Operation ${index}: ${op.type}
1749
1881
  try {
@@ -1751,9 +1883,9 @@ throw new Error('Unknown diagram mode action: ${action}');
1751
1883
  ${opCode}
1752
1884
  })();
1753
1885
  results.push({ index: ${index}, success: true, result: result${index} });
1754
- if (result${index} && result${index}.itemId) {
1886
+ ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
1755
1887
  itemIds.push(result${index}.itemId);
1756
- }
1888
+ }` : ''}
1757
1889
  } catch (opError) {
1758
1890
  results.push({ index: ${index}, success: false, error: opError.message });
1759
1891
  ${isAtomic ? 'throw opError;' : 'success = false;'}
@@ -1777,18 +1909,44 @@ throw new Error('Unknown diagram mode action: ${action}');
1777
1909
  /**
1778
1910
  * Generate code for a single batch operation
1779
1911
  */
1780
- generateBatchOperationCode(op, index) {
1912
+ generateBatchOperationCode(op, index, mergePlan) {
1781
1913
  switch (op.type) {
1782
- case 'create':
1914
+ case 'create': {
1783
1915
  const pos = op.position || { x: 400, y: 300 };
1784
- const props = JSON.stringify(op.properties || {});
1785
- return `
1916
+ const createProps = (op.properties || {});
1917
+ const props = JSON.stringify(createProps);
1918
+ let createCode = `
1786
1919
  const item = app.create('${op.itemType}', { position: { x: ${pos.x}, y: ${pos.y} }, ...${props} });
1787
- const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item, '${op.itemType}', { source: 'mcp-batch' });
1920
+ const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item, '${op.itemType}', { source: 'mcp-batch' });`;
1921
+ // app.create() does not read blendMode/opacity from its params, but the
1922
+ // underlying Paper.js item supports them directly — apply post-create so
1923
+ // callers can pass them inline in `properties` and have them take effect.
1924
+ if (createProps.blendMode !== undefined) {
1925
+ createCode += `\nif ('blendMode' in item) item.blendMode = ${JSON.stringify(createProps.blendMode)};`;
1926
+ }
1927
+ if (createProps.opacity !== undefined) {
1928
+ createCode += `\nif ('opacity' in item) item.opacity = ${JSON.stringify(createProps.opacity)};`;
1929
+ }
1930
+ // Coordinate-built items (path from segments/pathData, line/arc from
1931
+ // from/through/to) derive their geometry from those coordinates and
1932
+ // IGNORE params.position — so `create` at a point silently produced an
1933
+ // item at its raw coordinates instead. Re-seat it after create when the
1934
+ // caller explicitly asked for a position. Guarded on an explicit
1935
+ // op.position so absolute-coordinate geometry (the common case, which
1936
+ // passes no position) is never snapped to the injected default.
1937
+ if (op.position && isCoordinateBuilt(op.itemType, createProps)) {
1938
+ createCode += `
1939
+ // create() builds this item type from its own coordinates and ignores
1940
+ // params.position — apply the caller's explicit position after the fact.
1941
+ if (item.position) item.position = new paper.Point(${pos.x}, ${pos.y});`;
1942
+ }
1943
+ createCode += `
1788
1944
  // Ensure item is visible above backgrounds/generators
1789
1945
  if (item.bringToFront) item.bringToFront();
1790
1946
  return { itemId };
1791
1947
  `;
1948
+ return createCode;
1949
+ }
1792
1950
  case 'modify':
1793
1951
  const itemRef = op.itemId?.startsWith('$')
1794
1952
  ? `itemIds[${op.itemId.substring(1)}]`
@@ -1842,6 +2000,17 @@ item.remove();
1842
2000
  if (app.itemRegistry) app.itemRegistry.unregister(targetId);
1843
2001
  return { itemId: targetId, deleted: true };
1844
2002
  `;
2003
+ case 'group': {
2004
+ // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
2005
+ const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `itemIds[${ref.substring(1)}]` : `'${ref}'`);
2006
+ const idsArrayExpr = `[${refs.join(', ')}]`;
2007
+ const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
2008
+ // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
2009
+ return `
2010
+ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
2011
+ })();
2012
+ `;
2013
+ }
1845
2014
  case 'set_background':
1846
2015
  const bgColor = op.backgroundColor || op.properties?.color || '#000000';
1847
2016
  return `
@@ -1851,9 +2020,10 @@ return { success: true, backgroundColor: '${bgColor}' };
1851
2020
  case 'execute_generator':
1852
2021
  const genName = op.generatorName || 'drawSunburst';
1853
2022
  const genParams = JSON.stringify(op.generatorParams || {});
2023
+ const genOptions = JSON.stringify(op.generatorRegion ? { region: op.generatorRegion } : {});
1854
2024
  return `
1855
- await app.executeGenerator('${genName}', ${genParams});
1856
- return { success: true, generator: '${genName}' };
2025
+ await app.executeGenerator('${genName}', ${genParams}, ${genOptions});
2026
+ return { success: true, generator: '${genName}'${op.generatorRegion ? ', region: ' + JSON.stringify(op.generatorRegion) : ''} };
1857
2027
  `;
1858
2028
  case 'set_canvas_size': {
1859
2029
  const w = op.width || 1080;
@@ -1866,12 +2036,25 @@ return { success: true, width: ${w}, height: ${h} };
1866
2036
  `;
1867
2037
  }
1868
2038
  case 'keyframe_animate': {
2039
+ // Folded into an earlier op on the same target (see planKeyframeMerges).
2040
+ const foldTarget = mergePlan?.foldedInto.get(index);
2041
+ if (foldTarget !== undefined) {
2042
+ return `
2043
+ // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
2044
+ // is replaced wholesale, so emitting both here would silently drop one.
2045
+ return { itemId: ${op.itemId?.startsWith('$') ? `itemIds[${op.itemId.substring(1)}]` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
2046
+ `;
2047
+ }
2048
+ const mergedPayload = mergePlan?.merged.get(index);
1869
2049
  const kfItemRef = op.itemId?.startsWith('$')
1870
2050
  ? `itemIds[${op.itemId.substring(1)}]`
1871
2051
  : `'${op.itemId}'`;
1872
- const kfJson = JSON.stringify(op.keyframes || []);
1873
- const kfDuration = op.duration || (op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 5);
1874
- const kfLoop = op.loop ?? false;
2052
+ const kfSource = mergedPayload ? mergedPayload.keyframes : (op.keyframes || []);
2053
+ const kfJson = JSON.stringify(normalizeKeyframePositions(kfSource));
2054
+ const kfDuration = mergedPayload
2055
+ ? mergedPayload.duration
2056
+ : (op.duration || (op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 5));
2057
+ const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? false);
1875
2058
  return `
1876
2059
  const targetId = ${kfItemRef};
1877
2060
  app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop} });
@@ -1883,16 +2066,14 @@ return { itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop} };
1883
2066
  ? `itemIds[${op.itemId.substring(1)}]`
1884
2067
  : `'${op.itemId}'`;
1885
2068
  const maskOpts = JSON.stringify(op.maskOptions || {});
2069
+ const preset = op.maskPreset || op.maskType || 'wipeLeft';
1886
2070
  return `
1887
2071
  const targetId = ${maskItemRef};
1888
- if (!app.maskSystem) throw new Error('Mask system not available');
1889
- const result = app.maskSystem.applyAnimatedMask(targetId, {
1890
- preset: ${op.maskPreset ? `'${op.maskPreset}'` : 'null'},
1891
- maskType: ${op.maskType ? `'${op.maskType}'` : 'null'},
1892
- options: ${maskOpts},
1893
- maskOptions: ${maskOpts}
1894
- });
1895
- return result;
2072
+ const item = app.getItemById(targetId);
2073
+ if (!item) throw new Error('Item not found: ' + targetId);
2074
+ if (!app.applyAnimatedMask) throw new Error('Mask system not available');
2075
+ const maskedGroup = app.applyAnimatedMask(item, '${preset}', ${maskOpts});
2076
+ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
1896
2077
  `;
1897
2078
  }
1898
2079
  case 'apply_effect': {
@@ -1914,7 +2095,7 @@ return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
1914
2095
  const ptLoop = op.loop ?? false;
1915
2096
  if (ptAction === 'play') {
1916
2097
  return `
1917
- app.playKeyframeTimeline(${ptDuration}, ${ptLoop});
2098
+ app.playKeyframeTimeline(${ptDuration}, ${ptLoop}, { timeUnits: 'seconds' });
1918
2099
  return { success: true, action: 'play', duration: ${ptDuration}, loop: ${ptLoop} };
1919
2100
  `;
1920
2101
  }
@@ -1940,8 +2121,9 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
1940
2121
  */
1941
2122
  generateAgentExport(input) {
1942
2123
  const validated = AgentExportInputSchema.parse(input);
1943
- const { platform, format, quality, framing } = validated;
2124
+ const { platform, format, quality, framing, duration } = validated;
1944
2125
  const qualityLevel = quality || 'standard';
2126
+ const videoDuration = duration ?? 5;
1945
2127
  // Quality settings (bitrate in bps for VideoEncoder)
1946
2128
  const qualitySettings = {
1947
2129
  draft: { compression: 0.6, fps: 15, dpi: 72, bitrate: 2_000_000 },
@@ -2055,7 +2237,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2055
2237
  // ExportEngine._quickExportVideo overrides quality with undefined for
2056
2238
  // mp4/webm, _calculateBitrate yields NaN, and VideoEncoder.configure
2057
2239
  // rejects with 'malformed bitrate value'.
2058
- const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: 5 };
2240
+ const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration} };
2059
2241
  const blobToDataUrl = (b) => new Promise(resolve => {
2060
2242
  const r = new FileReader();
2061
2243
  r.onloadend = () => resolve(r.result);
@@ -2178,14 +2360,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2178
2360
  if (item.shadowColor || item.shadowBlur) hasShadow = true;
2179
2361
  });
2180
2362
 
2181
- // Check animation manager for registered animations
2182
- if (app.animationManager) {
2183
- const animations = app.animationManager.getAll ? app.animationManager.getAll() : (app.animationManager.animations || []);
2184
- if (animations.length > 0) {
2185
- analysis.hasAnimations = true;
2186
- animationSet.add('managed');
2187
- }
2188
- }
2363
+ // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2189
2364
  // Check timeline for active animations
2190
2365
  if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
2191
2366
  analysis.hasAnimations = true;
@@ -2249,257 +2424,6 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2249
2424
  suggestedPlatforms
2250
2425
  };
2251
2426
  })();
2252
- `.trim();
2253
- }
2254
- // ===========================================================================
2255
- // INTERACTIVE TRIGGER CODE GENERATORS
2256
- // ===========================================================================
2257
- /**
2258
- * Generate code for adding a trigger
2259
- */
2260
- generateAddTrigger(input) {
2261
- const validated = AddTriggerInputSchema.parse(input);
2262
- const { itemId, event, actions, condition, timelineOffset } = validated;
2263
- const actionsStr = JSON.stringify(actions);
2264
- const conditionStr = condition ? `'${condition.replace(/'/g, "\\'")}'` : 'null';
2265
- const offsetStr = timelineOffset !== undefined ? timelineOffset : 'null';
2266
- return `
2267
- // Add trigger to ${itemId}
2268
- const item = app.getItemById('${itemId}');
2269
- if (!item) throw new Error('Item not found: ${itemId}');
2270
-
2271
- const trigger = {
2272
- event: '${event}',
2273
- actions: ${actionsStr},
2274
- condition: ${conditionStr},
2275
- timelineOffset: ${offsetStr}
2276
- };
2277
-
2278
- // Initialize triggers array if needed
2279
- if (!item.data) item.data = {};
2280
- if (!item.data.triggers) item.data.triggers = [];
2281
-
2282
- // Add trigger
2283
- item.data.triggers.push(trigger);
2284
-
2285
- // Register event handler
2286
- if (app.triggerManager) {
2287
- app.triggerManager.registerTrigger(item, trigger);
2288
- }
2289
-
2290
- app.historyManager.saveState();
2291
- ({ success: true, itemId: '${itemId}', event: '${event}', triggerId: item.data.triggers.length - 1 });
2292
- `.trim();
2293
- }
2294
- /**
2295
- * Generate code for removing a trigger
2296
- */
2297
- generateRemoveTrigger(input) {
2298
- const validated = RemoveTriggerInputSchema.parse(input);
2299
- const { itemId, event, removeAll } = validated;
2300
- if (removeAll) {
2301
- return `
2302
- // Remove all triggers from ${itemId}
2303
- const item = app.getItemById('${itemId}');
2304
- if (!item) throw new Error('Item not found: ${itemId}');
2305
-
2306
- const removedCount = item.data?.triggers?.length || 0;
2307
- if (item.data) item.data.triggers = [];
2308
-
2309
- if (app.triggerManager) {
2310
- app.triggerManager.unregisterAll(item);
2311
- }
2312
-
2313
- app.historyManager.saveState();
2314
- ({ success: true, itemId: '${itemId}', removedCount, removeAll: true });
2315
- `.trim();
2316
- }
2317
- return `
2318
- // Remove ${event} trigger from ${itemId}
2319
- const item = app.getItemById('${itemId}');
2320
- if (!item) throw new Error('Item not found: ${itemId}');
2321
-
2322
- let removedCount = 0;
2323
- if (item.data?.triggers) {
2324
- const before = item.data.triggers.length;
2325
- item.data.triggers = item.data.triggers.filter(t => t.event !== '${event}');
2326
- removedCount = before - item.data.triggers.length;
2327
- }
2328
-
2329
- if (app.triggerManager) {
2330
- app.triggerManager.unregisterEvent(item, '${event}');
2331
- }
2332
-
2333
- app.historyManager.saveState();
2334
- ({ success: true, itemId: '${itemId}', event: '${event}', removedCount });
2335
- `.trim();
2336
- }
2337
- /**
2338
- * Generate code for querying triggers
2339
- */
2340
- generateQueryTriggers(input) {
2341
- const validated = QueryTriggersInputSchema.parse(input);
2342
- const { itemId, event } = validated;
2343
- if (itemId) {
2344
- return `
2345
- // Query triggers for ${itemId}
2346
- const item = app.getItemById('${itemId}');
2347
- if (!item) throw new Error('Item not found: ${itemId}');
2348
-
2349
- let triggers = item.data?.triggers || [];
2350
- ${event ? `triggers = triggers.filter(t => t.event === '${event}');` : ''}
2351
-
2352
- ({ success: true, itemId: '${itemId}', triggers, count: triggers.length });
2353
- `.trim();
2354
- }
2355
- return `
2356
- // Query all triggers
2357
- const allTriggers = [];
2358
- const items = app.itemRegistry ? app.itemRegistry.getAll() : [];
2359
-
2360
- items.forEach(entry => {
2361
- const item = entry.item;
2362
- if (item.data?.triggers?.length > 0) {
2363
- let triggers = item.data.triggers;
2364
- ${event ? `triggers = triggers.filter(t => t.event === '${event}');` : ''}
2365
- triggers.forEach((trigger, idx) => {
2366
- allTriggers.push({
2367
- itemId: entry.id,
2368
- triggerId: idx,
2369
- ...trigger
2370
- });
2371
- });
2372
- }
2373
- });
2374
-
2375
- ({ success: true, triggers: allTriggers, count: allTriggers.length${event ? `, event: '${event}'` : ''} });
2376
- `.trim();
2377
- }
2378
- // ===========================================================================
2379
- // QUIZ CODE GENERATORS
2380
- // ===========================================================================
2381
- /**
2382
- * Generate code for creating a quiz
2383
- */
2384
- generateCreateQuiz(input) {
2385
- const validated = CreateQuizInputSchema.parse(input);
2386
- const { title, questions, passingScore, showScore, allowRetry, shuffleQuestions, shuffleOptions } = validated;
2387
- const quizConfig = JSON.stringify({
2388
- title: title || 'Untitled Quiz',
2389
- questions,
2390
- passingScore: passingScore || 70,
2391
- showScore: showScore !== false,
2392
- allowRetry: allowRetry !== false,
2393
- shuffleQuestions: shuffleQuestions || false,
2394
- shuffleOptions: shuffleOptions || false,
2395
- }, null, 2);
2396
- return `
2397
- // Create quiz
2398
- const quizConfig = ${quizConfig};
2399
-
2400
- // Initialize quiz manager if needed
2401
- if (!app.quizManager) {
2402
- app.quizManager = {
2403
- quizzes: [],
2404
- activeQuiz: null,
2405
- createQuiz: function(config) {
2406
- const quiz = {
2407
- id: 'quiz_' + Date.now(),
2408
- ...config,
2409
- state: {
2410
- currentQuestion: 0,
2411
- answers: [],
2412
- score: 0,
2413
- maxScore: config.questions.reduce((sum, q) => sum + q.points, 0),
2414
- complete: false
2415
- }
2416
- };
2417
- this.quizzes.push(quiz);
2418
- this.activeQuiz = quiz;
2419
- return quiz;
2420
- }
2421
- };
2422
- }
2423
-
2424
- const quiz = app.quizManager.createQuiz(quizConfig);
2425
- app.historyManager.saveState();
2426
-
2427
- ({
2428
- success: true,
2429
- quizId: quiz.id,
2430
- title: quiz.title,
2431
- questionCount: quiz.questions.length,
2432
- maxScore: quiz.state.maxScore,
2433
- passingScore: quiz.passingScore
2434
- });
2435
- `.trim();
2436
- }
2437
- /**
2438
- * Generate code for getting quiz state
2439
- */
2440
- generateGetQuizState(input) {
2441
- const validated = GetQuizStateInputSchema.parse(input);
2442
- const { quizId } = validated;
2443
- const quizRef = quizId
2444
- ? `app.quizManager.quizzes.find(q => q.id === '${quizId}')`
2445
- : 'app.quizManager?.activeQuiz';
2446
- return `
2447
- // Get quiz state
2448
- if (!app.quizManager) {
2449
- return { success: false, error: 'No quiz manager initialized' };
2450
- }
2451
-
2452
- const quiz = ${quizRef};
2453
- if (!quiz) {
2454
- return { success: false, error: 'Quiz not found' };
2455
- }
2456
-
2457
- ({
2458
- success: true,
2459
- quizId: quiz.id,
2460
- title: quiz.title,
2461
- currentQuestion: quiz.state.currentQuestion,
2462
- totalQuestions: quiz.questions.length,
2463
- score: quiz.state.score,
2464
- maxScore: quiz.state.maxScore,
2465
- answers: quiz.state.answers,
2466
- complete: quiz.state.complete,
2467
- passed: quiz.state.complete ? (quiz.state.score / quiz.state.maxScore * 100) >= quiz.passingScore : null
2468
- });
2469
- `.trim();
2470
- }
2471
- /**
2472
- * Generate code for resetting a quiz
2473
- */
2474
- generateResetQuiz(input) {
2475
- const validated = ResetQuizInputSchema.parse(input);
2476
- const { quizId } = validated;
2477
- const quizRef = quizId
2478
- ? `app.quizManager.quizzes.find(q => q.id === '${quizId}')`
2479
- : 'app.quizManager?.activeQuiz';
2480
- return `
2481
- // Reset quiz
2482
- if (!app.quizManager) {
2483
- return { success: false, error: 'No quiz manager initialized' };
2484
- }
2485
-
2486
- const quiz = ${quizRef};
2487
- if (!quiz) {
2488
- return { success: false, error: 'Quiz not found' };
2489
- }
2490
-
2491
- // Reset state
2492
- quiz.state = {
2493
- currentQuestion: 0,
2494
- answers: [],
2495
- score: 0,
2496
- maxScore: quiz.questions.reduce((sum, q) => sum + q.points, 0),
2497
- complete: false
2498
- };
2499
-
2500
- app.historyManager.saveState();
2501
-
2502
- ({ success: true, quizId: quiz.id, reset: true });
2503
2427
  `.trim();
2504
2428
  }
2505
2429
  // ===========================================================================
@@ -2510,7 +2434,7 @@ app.historyManager.saveState();
2510
2434
  */
2511
2435
  generateCreateLetterCollage(input) {
2512
2436
  const validated = CreateLetterCollageInputSchema.parse(input);
2513
- const { text, style = 'tile', palette, position, fontSize = 48, fontFamily = 'Inter, sans-serif', spacing = 1.1, gradientPalette, gradientDirection = 'vertical', cornerRadius = 4, shadowEnabled = true, } = validated;
2437
+ const { text, style = 'tile', palette, position, fontSize = 48, fontFamily = 'Inter, sans-serif', spacing = 1.1, gradientPalette, gradientDirection = 'vertical', cornerRadius = 4, shadowEnabled = true, particleSpacing, particleDotSize, } = validated;
2514
2438
  // Build options object
2515
2439
  const options = {
2516
2440
  style,
@@ -2528,6 +2452,10 @@ app.historyManager.saveState();
2528
2452
  options.gradientPalette = gradientPalette;
2529
2453
  if (gradientDirection)
2530
2454
  options.gradientDirection = gradientDirection;
2455
+ if (particleSpacing !== undefined)
2456
+ options.particleSpacing = particleSpacing;
2457
+ if (particleDotSize !== undefined)
2458
+ options.particleDotSize = particleDotSize;
2531
2459
  return `
2532
2460
  // Create letter collage
2533
2461
  (function() {
@@ -2588,80 +2516,6 @@ app.historyManager.saveState();
2588
2516
 
2589
2517
  return result;
2590
2518
  })();
2591
- `.trim();
2592
- }
2593
- /**
2594
- * Generate code to get letter collage options
2595
- */
2596
- generateGetLetterCollageOptions() {
2597
- return `
2598
- // Get letter collage options
2599
- (function() {
2600
- if (!app.getLetterCollageOptions) {
2601
- // Return static options if function not available
2602
- return {
2603
- success: true,
2604
- styles: ['tile', 'magazine', 'paperCut', 'fold', 'gradient', 'image'],
2605
- tilePalettes: {
2606
- game: ['wordle', 'scrabble'],
2607
- vibrant: ['candy', 'neon', 'rainbow'],
2608
- soft: ['pastel', 'cotton'],
2609
- natural: ['earth', 'ocean', 'forest', 'sunset'],
2610
- professional: ['corporate', 'minimal', 'slate'],
2611
- seasonal: ['christmas', 'halloween', 'spring'],
2612
- magazine: ['magazine', 'newspaper', 'vintage'],
2613
- paperCraft: ['paperCraft', 'origami', 'craftPaper']
2614
- },
2615
- gradientPalettes: ['rainbow', 'sunset', 'ocean', 'fire', 'gold', 'rose', 'ice', 'cyberpunk', 'neonGlow', 'purplePink'],
2616
- gradientDirections: ['vertical', 'horizontal', 'diagonal', 'radial']
2617
- };
2618
- }
2619
-
2620
- return app.getLetterCollageOptions();
2621
- })();
2622
- `.trim();
2623
- }
2624
- // ===========================================================================
2625
- // CANVAS PRESETS CODE GENERATORS
2626
- // ===========================================================================
2627
- /**
2628
- * Generate code to get canvas presets
2629
- */
2630
- generateGetCanvasPresets() {
2631
- return `
2632
- // Get canvas presets
2633
- (function() {
2634
- if (app.getCanvasPresets) {
2635
- return app.getCanvasPresets();
2636
- }
2637
-
2638
- // Return static presets if function not available
2639
- return {
2640
- success: true,
2641
- presets: [
2642
- { key: 'youtube-thumbnail', name: 'YouTube Thumbnail', width: 1280, height: 720, aspectRatio: '16:9', category: 'video' },
2643
- { key: 'youtube-short', name: 'YouTube Shorts', width: 1080, height: 1920, aspectRatio: '9:16', category: 'video' },
2644
- { key: 'tiktok', name: 'TikTok', width: 1080, height: 1920, aspectRatio: '9:16', category: 'video' },
2645
- { key: 'instagram-story', name: 'Instagram Story', width: 1080, height: 1920, aspectRatio: '9:16', category: 'social' },
2646
- { key: 'instagram-post', name: 'Instagram Post (Square)', width: 1080, height: 1080, aspectRatio: '1:1', category: 'social' },
2647
- { key: 'instagram-landscape', name: 'Instagram Landscape', width: 1080, height: 566, aspectRatio: '1.91:1', category: 'social' },
2648
- { key: 'instagram-portrait', name: 'Instagram Portrait', width: 1080, height: 1350, aspectRatio: '4:5', category: 'social' },
2649
- { key: 'facebook-post', name: 'Facebook Post', width: 1200, height: 630, aspectRatio: '1.91:1', category: 'social' },
2650
- { key: 'facebook-cover', name: 'Facebook Cover', width: 820, height: 312, aspectRatio: '2.63:1', category: 'social' },
2651
- { key: 'facebook-story', name: 'Facebook Story', width: 1080, height: 1920, aspectRatio: '9:16', category: 'social' },
2652
- { key: 'twitter-post', name: 'Twitter/X Post', width: 1200, height: 675, aspectRatio: '16:9', category: 'social' },
2653
- { key: 'twitter-header', name: 'Twitter/X Header', width: 1500, height: 500, aspectRatio: '3:1', category: 'social' },
2654
- { key: 'linkedin-post', name: 'LinkedIn Post', width: 1200, height: 627, aspectRatio: '1.91:1', category: 'social' },
2655
- { key: 'linkedin-banner', name: 'LinkedIn Banner', width: 1584, height: 396, aspectRatio: '4:1', category: 'social' },
2656
- { key: 'pinterest-pin', name: 'Pinterest Pin', width: 1000, height: 1500, aspectRatio: '2:3', category: 'social' },
2657
- { key: 'presentation-16x9', name: 'Presentation 16:9', width: 1920, height: 1080, aspectRatio: '16:9', category: 'presentation' },
2658
- { key: 'presentation-4x3', name: 'Presentation 4:3', width: 1024, height: 768, aspectRatio: '4:3', category: 'presentation' },
2659
- { key: 'hd-720p', name: 'HD 720p', width: 1280, height: 720, aspectRatio: '16:9', category: 'video' },
2660
- { key: 'full-hd-1080p', name: 'Full HD 1080p', width: 1920, height: 1080, aspectRatio: '16:9', category: 'video' },
2661
- { key: 'default', name: 'Default', width: 800, height: 600, aspectRatio: '4:3', category: 'general' }
2662
- ]
2663
- };
2664
- })();
2665
2519
  `.trim();
2666
2520
  }
2667
2521
  // ===========================================================================
@@ -3185,54 +3039,246 @@ app.historyManager.saveState();
3185
3039
  })();
3186
3040
  `.trim();
3187
3041
  }
3042
+ // -------------------------------------------------------------------------
3043
+ // Globe + World Tour
3044
+ // -------------------------------------------------------------------------
3188
3045
  /**
3189
- * Generate code to export the complete scene state
3046
+ * Generate code to enable globe (orthographic) mode
3190
3047
  */
3191
- generateExportScene() {
3048
+ generateGlobeEnable(input) {
3049
+ const validated = GlobeEnableInputSchema.parse(input);
3050
+ const optionsStr = JSON.stringify(validated);
3192
3051
  return `
3193
- // Export complete scene state
3052
+ // Enable globe mode
3194
3053
  (function() {
3195
- try {
3196
- const items = [];
3197
- const relations = [];
3198
- const decorative = [];
3199
-
3200
- // Collect items from registry
3201
- if (app.itemRegistry) {
3202
- for (const [id, item] of app.itemRegistry.entries()) {
3203
- const itemData = {
3204
- id: id,
3205
- type: item.data?.type || item.className?.toLowerCase() || 'unknown',
3206
- position: item.position ? { x: item.position.x, y: item.position.y } : null,
3207
- bounds: item.bounds ? {
3208
- x: item.bounds.x,
3209
- y: item.bounds.y,
3210
- width: item.bounds.width,
3211
- height: item.bounds.height
3212
- } : null,
3213
- properties: {}
3214
- };
3215
-
3216
- // Collect common properties
3217
- if (item.fillColor) itemData.properties.fillColor = item.fillColor.toCSS ? item.fillColor.toCSS() : item.fillColor;
3218
- if (item.strokeColor) itemData.properties.strokeColor = item.strokeColor.toCSS ? item.strokeColor.toCSS() : item.strokeColor;
3219
- if (item.strokeWidth) itemData.properties.strokeWidth = item.strokeWidth;
3220
- if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3221
- if (item.rotation) itemData.properties.rotation = item.rotation;
3222
- if (item.data?.content) itemData.properties.content = item.data.content;
3054
+ if (!app.mapSystem) {
3055
+ return { success: false, error: 'Map system not available' };
3056
+ }
3057
+ if (typeof app.mapSystem.enableGlobeMode !== 'function') {
3058
+ return { success: false, error: 'enableGlobeMode unavailable on this build (update the canvas)' };
3059
+ }
3223
3060
 
3224
- if (item.data?.isDecorative) {
3225
- decorative.push(itemData);
3226
- } else {
3227
- items.push(itemData);
3228
- }
3229
- }
3061
+ try {
3062
+ app.mapSystem.enableGlobeMode(${optionsStr});
3063
+ return { success: true };
3064
+ } catch (error) {
3065
+ return { success: false, error: error.message };
3066
+ }
3067
+ })();
3068
+ `.trim();
3230
3069
  }
3231
-
3232
- // Collect relations
3233
- if (app.getRelations) {
3234
- const activeRelations = app.getRelations();
3235
- for (const rel of activeRelations) {
3070
+ /**
3071
+ * Generate code to rotate the globe to a coordinate (versor slerp)
3072
+ */
3073
+ generateGlobeRotateTo(input) {
3074
+ const validated = GlobeRotateToInputSchema.parse(input);
3075
+ const durationArg = validated.duration !== undefined ? `, ${validated.duration}` : '';
3076
+ return `
3077
+ // Rotate globe to coordinate
3078
+ (function() {
3079
+ if (!app.mapSystem) {
3080
+ return { success: false, error: 'Map system not available' };
3081
+ }
3082
+ if (typeof app.mapSystem.rotateGlobeTo !== 'function') {
3083
+ return { success: false, error: 'rotateGlobeTo unavailable on this build (update the canvas)' };
3084
+ }
3085
+
3086
+ try {
3087
+ app.mapSystem.rotateGlobeTo(${validated.lon}, ${validated.lat}${durationArg});
3088
+ return { success: true, rotateTo: [${validated.lon}, ${validated.lat}] };
3089
+ } catch (error) {
3090
+ return { success: false, error: error.message };
3091
+ }
3092
+ })();
3093
+ `.trim();
3094
+ }
3095
+ /**
3096
+ * Generate code to continuously spin the globe
3097
+ */
3098
+ generateGlobeSpin(input) {
3099
+ const validated = GlobeSpinInputSchema.parse(input);
3100
+ const optionsStr = JSON.stringify(validated);
3101
+ return `
3102
+ // Spin globe continuously
3103
+ (function() {
3104
+ if (!app.mapSystem) {
3105
+ return { success: false, error: 'Map system not available' };
3106
+ }
3107
+ if (typeof app.mapSystem.animateGlobeRotation !== 'function') {
3108
+ return { success: false, error: 'animateGlobeRotation unavailable on this build (update the canvas)' };
3109
+ }
3110
+
3111
+ try {
3112
+ const callbackId = app.mapSystem.animateGlobeRotation(${optionsStr});
3113
+ return { success: true, callbackId: callbackId || null };
3114
+ } catch (error) {
3115
+ return { success: false, error: error.message };
3116
+ }
3117
+ })();
3118
+ `.trim();
3119
+ }
3120
+ /**
3121
+ * Generate code to run a world tour across regions or coordinates
3122
+ */
3123
+ generateWorldTour(input) {
3124
+ const validated = WorldTourInputSchema.parse(input);
3125
+ const optionsStr = JSON.stringify(validated);
3126
+ return `
3127
+ // World tour across regions / coordinates
3128
+ (function() {
3129
+ if (!app.mapSystem) {
3130
+ return { success: false, error: 'Map system not available' };
3131
+ }
3132
+ if (typeof app.mapSystem.worldTour !== 'function') {
3133
+ return { success: false, error: 'worldTour unavailable on this build (update the canvas)' };
3134
+ }
3135
+
3136
+ try {
3137
+ const tourId = app.mapSystem.worldTour(${optionsStr});
3138
+ return { success: true, tourId: tourId || null };
3139
+ } catch (error) {
3140
+ return { success: false, error: error.message };
3141
+ }
3142
+ })();
3143
+ `.trim();
3144
+ }
3145
+ /**
3146
+ * Generate code to stop a running world tour
3147
+ */
3148
+ generateStopWorldTour(input) {
3149
+ const validated = StopWorldTourInputSchema.parse(input);
3150
+ const idArg = validated.id !== undefined ? JSON.stringify(validated.id) : '';
3151
+ return `
3152
+ // Stop world tour
3153
+ (function() {
3154
+ if (!app.mapSystem) {
3155
+ return { success: false, error: 'Map system not available' };
3156
+ }
3157
+ if (typeof app.mapSystem.stopWorldTour !== 'function') {
3158
+ return { success: false, error: 'stopWorldTour unavailable on this build (update the canvas)' };
3159
+ }
3160
+
3161
+ try {
3162
+ app.mapSystem.stopWorldTour(${idArg});
3163
+ return { success: true };
3164
+ } catch (error) {
3165
+ return { success: false, error: error.message };
3166
+ }
3167
+ })();
3168
+ `.trim();
3169
+ }
3170
+ /**
3171
+ * Generate code to pin a canvas item to a globe surface coordinate
3172
+ */
3173
+ generatePinToGlobe(input) {
3174
+ const validated = PinToGlobeInputSchema.parse(input);
3175
+ const itemId = validated.itemId;
3176
+ const opts = JSON.stringify({ hideOnFarSide: validated.hideOnFarSide });
3177
+ return `
3178
+ // Pin item to globe coordinate
3179
+ (function() {
3180
+ if (!app.mapSystem) {
3181
+ return { success: false, error: 'Map system not available' };
3182
+ }
3183
+ if (typeof app.mapSystem.pinItemToGlobe !== 'function') {
3184
+ return { success: false, error: 'pinItemToGlobe unavailable on this build (update the canvas)' };
3185
+ }
3186
+
3187
+ const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
3188
+ if (!item) {
3189
+ return { success: false, error: 'Item ${itemId} not found' };
3190
+ }
3191
+
3192
+ try {
3193
+ const pinned = app.mapSystem.pinItemToGlobe(item, ${validated.lon}, ${validated.lat}, ${opts});
3194
+ return { success: !!pinned, pinned: !!pinned };
3195
+ } catch (error) {
3196
+ return { success: false, error: error.message };
3197
+ }
3198
+ })();
3199
+ `.trim();
3200
+ }
3201
+ /**
3202
+ * Generate code to tour a canvas item along regions or coordinates
3203
+ */
3204
+ generateTourItem(input) {
3205
+ const validated = TourItemInputSchema.parse(input);
3206
+ const itemId = validated.itemId;
3207
+ const { itemId: _omit, regions, coords, ...rest } = validated;
3208
+ const optsStr = JSON.stringify(rest);
3209
+ return `
3210
+ // Tour item along regions / coordinates
3211
+ (function() {
3212
+ if (!app.mapSystem) {
3213
+ return { success: false, error: 'Map system not available' };
3214
+ }
3215
+
3216
+ const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
3217
+ if (!item) {
3218
+ return { success: false, error: 'Item ${itemId} not found' };
3219
+ }
3220
+
3221
+ try {
3222
+ ${regions
3223
+ ? `if (typeof app.mapSystem.tourRegions !== 'function') { return { success: false, error: 'tourRegions unavailable on this build (update the canvas)' }; }
3224
+ const tourId = app.mapSystem.tourRegions(item, ${JSON.stringify(regions)}, ${optsStr});`
3225
+ : `if (typeof app.mapSystem.tourItemAlongCoords !== 'function') { return { success: false, error: 'tourItemAlongCoords unavailable on this build (update the canvas)' }; }
3226
+ const tourId = app.mapSystem.tourItemAlongCoords(item, ${JSON.stringify(coords || [])}, ${optsStr});`}
3227
+ return { success: true, tourId: tourId || null };
3228
+ } catch (error) {
3229
+ return { success: false, error: error.message };
3230
+ }
3231
+ })();
3232
+ `.trim();
3233
+ }
3234
+ /**
3235
+ * Generate code to export the complete scene state
3236
+ */
3237
+ generateExportScene() {
3238
+ return `
3239
+ // Export complete scene state
3240
+ (function() {
3241
+ try {
3242
+ const items = [];
3243
+ const relations = [];
3244
+ const decorative = [];
3245
+
3246
+ // Collect items from registry
3247
+ if (app.itemRegistry) {
3248
+ for (const [id, item] of app.itemRegistry.entries()) {
3249
+ const itemData = {
3250
+ id: id,
3251
+ type: item.data?.type || item.className?.toLowerCase() || 'unknown',
3252
+ position: item.position ? { x: item.position.x, y: item.position.y } : null,
3253
+ bounds: item.bounds ? {
3254
+ x: item.bounds.x,
3255
+ y: item.bounds.y,
3256
+ width: item.bounds.width,
3257
+ height: item.bounds.height
3258
+ } : null,
3259
+ properties: {}
3260
+ };
3261
+
3262
+ // Collect common properties
3263
+ if (item.fillColor) itemData.properties.fillColor = item.fillColor.toCSS ? item.fillColor.toCSS() : item.fillColor;
3264
+ if (item.strokeColor) itemData.properties.strokeColor = item.strokeColor.toCSS ? item.strokeColor.toCSS() : item.strokeColor;
3265
+ if (item.strokeWidth) itemData.properties.strokeWidth = item.strokeWidth;
3266
+ if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3267
+ if (item.rotation) itemData.properties.rotation = item.rotation;
3268
+ if (item.data?.content) itemData.properties.content = item.data.content;
3269
+
3270
+ if (item.data?.isDecorative) {
3271
+ decorative.push(itemData);
3272
+ } else {
3273
+ items.push(itemData);
3274
+ }
3275
+ }
3276
+ }
3277
+
3278
+ // Collect relations
3279
+ if (app.getRelations) {
3280
+ const activeRelations = app.getRelations();
3281
+ for (const rel of activeRelations) {
3236
3282
  relations.push({
3237
3283
  sourceId: rel.sourceId,
3238
3284
  targetId: rel.targetId,
@@ -3446,22 +3492,27 @@ app.historyManager.saveState();
3446
3492
  const optionsStr = validated.options ? JSON.stringify(validated.options) : '{}';
3447
3493
  const keyframesStr = validated.keyframes ? JSON.stringify(validated.keyframes) : 'null';
3448
3494
  const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
3495
+ const preset = validated.preset || validated.maskType || 'wipeLeft';
3496
+ // Per-preset options. Custom keyframes are an animated-mask extension —
3497
+ // when supplied, fall through to maskingSystem.applyCustomMask instead.
3449
3498
  return `
3450
3499
  // Apply animated mask to ${validated.itemId}
3451
3500
  (function() {
3452
- if (!app.maskSystem) {
3501
+ if (!app.applyAnimatedMask) {
3453
3502
  return { success: false, error: 'Mask system not available' };
3454
3503
  }
3504
+ const item = app.getItemById('${validated.itemId}');
3505
+ if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
3455
3506
 
3456
3507
  try {
3457
- const result = app.maskSystem.applyAnimatedMask('${validated.itemId}', {
3458
- preset: ${validated.preset ? `'${validated.preset}'` : 'null'},
3459
- maskType: ${validated.maskType ? `'${validated.maskType}'` : 'null'},
3460
- keyframes: ${keyframesStr},
3461
- options: ${optionsStr},
3462
- maskOptions: ${maskOptionsStr}
3463
- });
3464
- return result;
3508
+ const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
3509
+ let maskedGroup;
3510
+ if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
3511
+ maskedGroup = app.maskingSystem.applyCustomMask(item, '${preset}', ${keyframesStr}, mergedOpts);
3512
+ } else {
3513
+ maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
3514
+ }
3515
+ return { success: !!maskedGroup, itemId: '${validated.itemId}', preset: '${preset}' };
3465
3516
  } catch (error) {
3466
3517
  return { success: false, error: error.message };
3467
3518
  }
@@ -3475,17 +3526,15 @@ app.historyManager.saveState();
3475
3526
  return `
3476
3527
  // Apply custom mask to ${validated.itemId}
3477
3528
  (function() {
3478
- if (!app.maskSystem) {
3529
+ if (!app.maskingSystem || !app.maskingSystem.applyCustomMask) {
3479
3530
  return { success: false, error: 'Mask system not available' };
3480
3531
  }
3532
+ const item = app.getItemById('${validated.itemId}');
3533
+ if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
3481
3534
 
3482
3535
  try {
3483
- const result = app.maskSystem.applyCustomMask('${validated.itemId}', {
3484
- maskType: '${validated.maskType}',
3485
- keyframes: ${keyframesStr},
3486
- maskOptions: ${maskOptionsStr}
3487
- });
3488
- return result;
3536
+ const maskedGroup = app.maskingSystem.applyCustomMask(item, '${validated.maskType}', ${keyframesStr}, ${maskOptionsStr});
3537
+ return { success: !!maskedGroup, itemId: '${validated.itemId}', maskType: '${validated.maskType}' };
3489
3538
  } catch (error) {
3490
3539
  return { success: false, error: error.message };
3491
3540
  }
@@ -3497,12 +3546,17 @@ app.historyManager.saveState();
3497
3546
  return `
3498
3547
  // Remove mask from ${validated.itemId}
3499
3548
  (function() {
3500
- if (!app.maskSystem) {
3549
+ if (!app.removeMask) {
3501
3550
  return { success: false, error: 'Mask system not available' };
3502
3551
  }
3552
+ // The item id may point at either the masked group OR the original item;
3553
+ // app.removeMask expects the masked group, so resolve and walk up if needed.
3554
+ const item = app.getItemById('${validated.itemId}');
3555
+ if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
3556
+ const maskedGroup = (item.data && item.data.isMaskedGroup) ? item : (item.parent && item.parent.data && item.parent.data.isMaskedGroup ? item.parent : item);
3503
3557
 
3504
3558
  try {
3505
- const result = app.maskSystem.removeMask('${validated.itemId}');
3559
+ app.removeMask(maskedGroup);
3506
3560
  return { success: true, itemId: '${validated.itemId}', message: 'Mask removed' };
3507
3561
  } catch (error) {
3508
3562
  return { success: false, error: error.message };
@@ -3596,10 +3650,12 @@ app.historyManager.saveState();
3596
3650
  })();
3597
3651
  `.trim();
3598
3652
  }
3653
+ // =============================================================================
3654
+ // TEMPLATE
3655
+ // =============================================================================
3599
3656
  generateApplyTemplate(input) {
3600
3657
  const { templateId, category, listOnly } = input;
3601
3658
  if (listOnly || !templateId) {
3602
- // List mode: return available templates, optionally filtered by category
3603
3659
  const categoryFilter = category ? `'${category}'` : 'null';
3604
3660
  return `
3605
3661
  // List available templates
@@ -3625,7 +3681,6 @@ app.historyManager.saveState();
3625
3681
  })();
3626
3682
  `.trim();
3627
3683
  }
3628
- // Load mode: apply a specific template
3629
3684
  return `
3630
3685
  // Apply template: ${templateId}
3631
3686
  (async function() {
@@ -3901,7 +3956,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3901
3956
  // Select all items
3902
3957
  (function() {
3903
3958
  app.selectAll();
3904
- const items = app.getSelection().map(i => i.data?.itemId || i.name || i.id);
3959
+ const items = app.getSelectedItems().map(i => i.data?.itemId || i.name || i.id);
3905
3960
  return { success: true, action: 'select_all', count: items.length, items };
3906
3961
  })();`.trim();
3907
3962
  case 'deselect_all':
@@ -3915,7 +3970,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3915
3970
  return `
3916
3971
  // Get current selection
3917
3972
  (function() {
3918
- const items = app.getSelection().map(i => ({
3973
+ const items = app.getSelectedItems().map(i => ({
3919
3974
  itemId: i.data?.itemId || i.name || i.id,
3920
3975
  type: i.data?.itemType || i.className,
3921
3976
  bounds: i.bounds ? { x: i.bounds.x, y: i.bounds.y, width: i.bounds.width, height: i.bounds.height } : null
@@ -3926,7 +3981,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
3926
3981
  return `
3927
3982
  // Delete selected items
3928
3983
  (function() {
3929
- const selection = app.getSelection();
3984
+ const selection = app.getSelectedItems();
3930
3985
  const deleted = selection.map(i => i.data?.itemId || i.name || i.id);
3931
3986
  app.deleteSelected();
3932
3987
  return { success: true, action: 'delete_selected', deleted, count: deleted.length };
@@ -4031,15 +4086,23 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4031
4086
  // IMAGE PROCESSING
4032
4087
  // ===========================================================================
4033
4088
  generateImageFilter(input) {
4034
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
4089
+ // Resolve the raster from the registry id (unwrapping groups when the
4090
+ // build has the _resolveRaster helper), then use the real GPU-filter
4091
+ // facades. The previous emitter called app.imageTools.applyFilter, a
4092
+ // method that never existed — the tool errored on every use.
4093
+ const resolve = ` const item = (typeof app._resolveRaster === 'function')
4094
+ ? app._resolveRaster(${JSON.stringify(input.itemId)})
4095
+ : (app.itemRegistry ? app.itemRegistry.getItem(${JSON.stringify(input.itemId)}) : null);
4096
+ if (!item) return { error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
4097
+ if (typeof app.applyImageFilter !== 'function') return { error: 'GPU image filters unavailable — update FxTool' };`;
4035
4098
  switch (input.action) {
4036
4099
  case 'apply':
4037
4100
  return `
4038
4101
  // Apply image filter
4039
4102
  (async function() {
4040
- ${guard}
4103
+ ${resolve}
4041
4104
  try {
4042
- const result = await app.imageTools.applyFilter(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(input.params || {})});
4105
+ await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(input.params || {})});
4043
4106
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
4044
4107
  } catch (e) {
4045
4108
  return { error: 'Failed to apply filter: ' + e.message };
@@ -4049,10 +4112,10 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4049
4112
  return `
4050
4113
  // Apply filter chain
4051
4114
  (async function() {
4052
- ${guard}
4115
+ ${resolve}
4053
4116
  try {
4054
4117
  const filters = ${JSON.stringify(input.filters || [])};
4055
- const result = await app.imageTools.applyFilterChain(${JSON.stringify(input.itemId)}, filters);
4118
+ await app.applyImageFilterChain(item, filters);
4056
4119
  return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
4057
4120
  } catch (e) {
4058
4121
  return { error: 'Failed to apply filter chain: ' + e.message };
@@ -4160,29 +4223,6 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4160
4223
  return `(function() { return { error: 'Unknown precomp action: ${input.action}' }; })();`;
4161
4224
  }
4162
4225
  }
4163
- generateView(input) {
4164
- switch (input.action) {
4165
- case 'fit': {
4166
- const mode = input.mode || 'content';
4167
- const padding = input.padding ?? 20;
4168
- return `
4169
- // Fit view to ${mode}
4170
- (function() {
4171
- app.fitView('${mode}', ${padding});
4172
- return { success: true, action: 'fit', mode: '${mode}', padding: ${padding} };
4173
- })();`.trim();
4174
- }
4175
- case 'get_state':
4176
- return `
4177
- // Get current view state
4178
- (function() {
4179
- const state = app.getViewState();
4180
- return { success: true, action: 'get_state', ...state };
4181
- })();`.trim();
4182
- default:
4183
- return `(function() { return { error: 'Unknown view action: ${input.action}' }; })();`;
4184
- }
4185
- }
4186
4226
  generateBackground(input) {
4187
4227
  switch (input.action) {
4188
4228
  case 'set': {
@@ -4226,8 +4266,9 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4226
4266
  return `
4227
4267
  // Get background info
4228
4268
  (function() {
4229
- const bg = app.getBackground();
4230
- return { success: true, action: 'get', ...bg };
4269
+ // FxTool exposes getBackgroundMode() returning the mode string only.
4270
+ const mode = app.getBackgroundMode ? app.getBackgroundMode() : null;
4271
+ return { success: true, action: 'get', mode };
4231
4272
  })();`.trim();
4232
4273
  default:
4233
4274
  return `(function() { return { error: 'Unknown background action: ${input.action}' }; })();`;
@@ -4435,61 +4476,6 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4435
4476
  }
4436
4477
  }
4437
4478
  // ===========================================================================
4438
- // STORAGE
4439
- // ===========================================================================
4440
- generateStorage(input) {
4441
- const guard = `if (!app.storageManager) return { error: 'StorageManager not available' };`;
4442
- switch (input.action) {
4443
- case 'save': {
4444
- const name = input.name ? JSON.stringify(input.name) : "'Untitled Project'";
4445
- const thumbnailCode = input.thumbnail
4446
- ? `const thumbnail = app.exportThumbnail ? app.exportThumbnail() : null;\n if (thumbnail) project.thumbnail = thumbnail;`
4447
- : '';
4448
- return `
4449
- // Save project
4450
- (async function() {
4451
- ${guard}
4452
- const project = { name: ${name}, data: app.serialize() };
4453
- ${thumbnailCode}
4454
- const projectId = await app.storageManager.saveProject(project);
4455
- return { success: true, action: 'save', projectId, name: ${name} };
4456
- })();`.trim();
4457
- }
4458
- case 'load': {
4459
- const projId = JSON.stringify(input.projectId || '');
4460
- return `
4461
- // Load project
4462
- (async function() {
4463
- ${guard}
4464
- const project = await app.storageManager.loadProject(${projId});
4465
- if (!project) return { error: 'Project not found: ' + ${projId} };
4466
- app.deserialize(project.data);
4467
- return { success: true, action: 'load', projectId: ${projId}, name: project.name };
4468
- })();`.trim();
4469
- }
4470
- case 'list':
4471
- return `
4472
- // List saved projects
4473
- (async function() {
4474
- ${guard}
4475
- const projects = await app.storageManager.listProjects();
4476
- return { success: true, action: 'list', projects: projects.map(p => ({ id: p.id, name: p.name, updatedAt: p.updatedAt })), count: projects.length };
4477
- })();`.trim();
4478
- case 'delete': {
4479
- const projId = JSON.stringify(input.projectId || '');
4480
- return `
4481
- // Delete project
4482
- (async function() {
4483
- ${guard}
4484
- await app.storageManager.deleteProject(${projId});
4485
- return { success: true, action: 'delete', projectId: ${projId} };
4486
- })();`.trim();
4487
- }
4488
- default:
4489
- return `(function() { return { error: 'Unknown storage action: ${input.action}' }; })();`;
4490
- }
4491
- }
4492
- // ===========================================================================
4493
4479
  // INTERACTION
4494
4480
  // ===========================================================================
4495
4481
  generateInteraction(input) {
@@ -4559,8 +4545,8 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4559
4545
  return `
4560
4546
  // Export widget (pp:PinePaper ontology JSON)
4561
4547
  (async function() {
4562
- if (!app.exportWidget) return { error: 'Widget export not available' };
4563
- const result = await app.exportWidget(${JSON.stringify(opts)});
4548
+ if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
4549
+ const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
4564
4550
  return { success: true, json: result.json, filename: result.filename, embedCode: result.embedCode, metadata: result.data?.metadata };
4565
4551
  })();`.trim();
4566
4552
  }
@@ -4573,8 +4559,8 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4573
4559
  return `
4574
4560
  // Export widget as self-contained HTML
4575
4561
  (async function() {
4576
- if (!app.exportWidgetHTML) return { error: 'Widget HTML export not available' };
4577
- const result = await app.exportWidgetHTML(${JSON.stringify(opts)});
4562
+ if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
4563
+ const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
4578
4564
  return { success: true, html: result.html, estimatedSize: result.estimatedSize, analysis: { itemTypes: [...result.analysis.itemTypes], relationTypes: [...result.analysis.relationTypes], hasSimpleAnimations: result.analysis.hasSimpleAnimations, hasKeyframeAnimations: result.analysis.hasKeyframeAnimations, hasMasks: result.analysis.hasMasks } };
4579
4565
  })();`.trim();
4580
4566
  }
@@ -4882,6 +4868,665 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} const ite
4882
4868
  return `(function() { return { error: 'Unknown measurement action: ${input.action}' }; })();`;
4883
4869
  }
4884
4870
  }
4871
+ /**
4872
+ * Z-order: select the item, then call the matching app stacking method
4873
+ * (bringToFront / sendToBack / bringForward / sendBackward).
4874
+ */
4875
+ generateArrange(input) {
4876
+ const method = {
4877
+ front: 'bringToFront',
4878
+ back: 'sendToBack',
4879
+ forward: 'bringForward',
4880
+ backward: 'sendBackward',
4881
+ }[input.action];
4882
+ const id = JSON.stringify(input.itemId);
4883
+ return `
4884
+ // Arrange ${input.itemId} → ${input.action}
4885
+ (function() {
4886
+ const item = app.getItemById(${id});
4887
+ if (!item) return { success: false, error: 'Item not found: ' + ${id} };
4888
+ if (typeof app.${method} !== 'function') return { success: false, error: 'app.${method} unavailable — update FxTool' };
4889
+ app.select(${id});
4890
+ app.${method}();
4891
+ return { success: true, itemId: ${id}, action: '${input.action}' };
4892
+ })();`.trim();
4893
+ }
4894
+ /**
4895
+ * On-device object detection (FxTool ImageWorkflow). Finds objects in an image;
4896
+ * with asNodes promotes each detection to a typed, image-anchored design node
4897
+ * (pp:Detected*) instead of a labeled box. Async (runs an on-device ML model).
4898
+ */
4899
+ generateDetectObjects(input) {
4900
+ const args = JSON.stringify({
4901
+ ...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
4902
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
4903
+ ...(input.asNodes !== undefined ? { asNodes: input.asNodes } : {}),
4904
+ ...(input.queries !== undefined ? { queries: input.queries } : {}),
4905
+ });
4906
+ return `
4907
+ // Detect objects${input.queries !== undefined ? ' (open-vocabulary / OWL-ViT)' : ''}${input.asNodes ? ' (asNodes — relational compositing)' : ''}
4908
+ (async function() {
4909
+ if (typeof app.detectObjects !== 'function') {
4910
+ return { success: false, error: 'app.detectObjects unavailable — update FxTool to a build that exposes on-device object detection on app' };
4911
+ }
4912
+ try {
4913
+ const res = await app.detectObjects(${args});
4914
+ return Object.assign({ success: !!(res && res.ok) }, res || {});
4915
+ } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
4916
+ })();`.trim();
4917
+ }
4918
+ /**
4919
+ * Extract the best-matching detected region from an image as a NEW item (FxTool
4920
+ * ImageWorkflow.extractObject). Async (runs detection first).
4921
+ */
4922
+ generateExtractObject(input) {
4923
+ const args = JSON.stringify({
4924
+ ...(input.label !== undefined ? { label: input.label } : {}),
4925
+ ...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
4926
+ ...(input.x !== undefined ? { x: input.x } : {}),
4927
+ ...(input.y !== undefined ? { y: input.y } : {}),
4928
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
4929
+ });
4930
+ return `
4931
+ // Extract object${input.label ? ` "${input.label}"` : ''} from the image
4932
+ (async function() {
4933
+ if (typeof app.extractObject !== 'function') {
4934
+ return { success: false, error: 'app.extractObject unavailable — update FxTool to a build that exposes on-device object detection on app' };
4935
+ }
4936
+ try {
4937
+ const res = await app.extractObject(${args});
4938
+ return Object.assign({ success: !!(res && res.ok) }, res || {});
4939
+ } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
4940
+ })();`.trim();
4941
+ }
4942
+ /**
4943
+ * Camera director (FxTool DirectorCompiler): compile a shot list into ONE
4944
+ * camera_animates walkthrough. 'auto' derives a shot per item; 'shots' applies
4945
+ * an explicit list. WYSIWYG between editor and any export size (one camera).
4946
+ */
4947
+ generateCameraDirector(input) {
4948
+ if (input.action === 'shots') {
4949
+ const shotsJson = JSON.stringify(input.shots ?? []);
4950
+ const opts = JSON.stringify({ loop: !!input.loop });
4951
+ return `
4952
+ // Camera director — apply explicit shot list
4953
+ (function() {
4954
+ if (typeof app.applyDirectorShots !== 'function') {
4955
+ return { success: false, error: 'app.applyDirectorShots unavailable — update FxTool to a build with the camera director' };
4956
+ }
4957
+ const shots = ${shotsJson};
4958
+ const ok = app.applyDirectorShots(shots, ${opts});
4959
+ return { success: !!ok, mode: 'shots', shotCount: shots.length, loop: ${!!input.loop} };
4960
+ })();`.trim();
4961
+ }
4962
+ const opts = JSON.stringify({
4963
+ ...(input.order !== undefined ? { order: input.order } : {}),
4964
+ ...(input.hold !== undefined ? { hold: input.hold } : {}),
4965
+ ...(input.establishing !== undefined ? { establishing: input.establishing } : {}),
4966
+ loop: !!input.loop,
4967
+ });
4968
+ return `
4969
+ // Camera director — auto-direct a walkthrough of the scene
4970
+ (function() {
4971
+ if (typeof app.autoDirectStory !== 'function') {
4972
+ return { success: false, error: 'app.autoDirectStory unavailable — update FxTool to a build with the camera director' };
4973
+ }
4974
+ const shots = app.autoDirectStory(${opts});
4975
+ if (!shots) return { success: false, mode: 'auto', error: 'No items to direct — add content first' };
4976
+ return { success: true, mode: 'auto', shotCount: shots.length, shots: shots };
4977
+ })();`.trim();
4978
+ }
4979
+ /**
4980
+ * Inner JS that groups resolved item ids into ONE draggable entity via the
4981
+ * GroupManager (createGroup → addItemsToGroup). idsArrayExpr is a JS expression
4982
+ * evaluating to an array of id strings; nameExpr is a JS string expression or 'undefined'.
4983
+ * Shared by the standalone pinepaper_group tool and the batch "group" operation.
4984
+ */
4985
+ groupCodeBody(idsArrayExpr, nameExpr) {
4986
+ return `
4987
+ if (!app.groupManager) return { success: false, error: 'GroupManager not available — update FxTool' };
4988
+ const ids = (${idsArrayExpr}).filter(function(x) { return x != null; });
4989
+ const items = ids.map(function(id) { return app.getItemById(id); }).filter(Boolean);
4990
+ if (!items.length) return { success: false, error: 'No valid items to group (check the ids / $N refs)' };
4991
+ const group = app.groupManager.createGroup(${nameExpr});
4992
+ if (!group) return { success: false, error: 'Failed to create group' };
4993
+ app.groupManager.addItemsToGroup(items, group);
4994
+ if (app.historyManager) app.historyManager.saveState();
4995
+ const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
4996
+ return { success: true, groupId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
4997
+ }
4998
+ /**
4999
+ * Group a set of items into one draggable entity, or ungroup one back into loose
5000
+ * items. Grouping is non-destructive — ungroup restores the individual items.
5001
+ */
5002
+ generateGroup(input) {
5003
+ if (input.action === 'break_apart') {
5004
+ const idExpr = JSON.stringify(input.itemId || '');
5005
+ return `
5006
+ // Break apart ${input.itemId || ''} into movable parts
5007
+ (function() {
5008
+ if (typeof app.breakApart !== 'function') return { success: false, error: 'app.breakApart unavailable — update FxTool to a build with SVG/group segmentation' };
5009
+ const target = app.getItemById(${idExpr});
5010
+ if (!target) return { success: false, error: 'Item not found: ' + ${idExpr} };
5011
+ const result = app.breakApart(target);
5012
+ if (!result || !result.parts || !result.parts.length) return { success: false, error: 'Nothing to break apart (no sub-parts found)' };
5013
+ const partIds = result.parts.map(function(p) { return p && p.data && (p.data.registryId || p.data.id); }).filter(Boolean);
5014
+ const groupId = (result.group && result.group.data && (result.group.data.registryId || result.group.data.groupId)) || null;
5015
+ return { success: true, action: 'break_apart', groupId: groupId, partIds: partIds, partCount: partIds.length };
5016
+ })();`.trim();
5017
+ }
5018
+ if (input.action === 'ungroup') {
5019
+ const idExpr = JSON.stringify(input.groupId || '');
5020
+ return `
5021
+ // Ungroup ${input.groupId || ''}
5022
+ (function() {
5023
+ if (!app.groupManager) return { success: false, error: 'GroupManager not available — update FxTool' };
5024
+ const gid = ${idExpr};
5025
+ const group = app.getItemById(gid) || (app.groupManager.getGroupById ? app.groupManager.getGroupById(gid) : null);
5026
+ if (!group) return { success: false, error: 'Group not found: ' + gid };
5027
+ app.groupManager.ungroupAll(group);
5028
+ if (app.historyManager) app.historyManager.saveState();
5029
+ return { success: true, ungrouped: true, groupId: gid };
5030
+ })();`.trim();
5031
+ }
5032
+ const idsArrayExpr = JSON.stringify(input.itemIds ?? []);
5033
+ const nameExpr = input.groupName !== undefined ? JSON.stringify(input.groupName) : 'undefined';
5034
+ return `
5035
+ // Group ${(input.itemIds || []).length} items${input.groupName ? ` as "${input.groupName}"` : ''}
5036
+ (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
5037
+ })();`.trim();
5038
+ }
5039
+ /**
5040
+ * Audit the live scene (or pre-validate a batch of proposed ops) with FxTool's
5041
+ * OntologyValidator — structured { ok, diagnostics } feedback instead of console warnings.
5042
+ */
5043
+ generateValidateScene(input) {
5044
+ const guard = `const v = app.sceneValidator;
5045
+ if (!v) return { success: false, error: 'app.sceneValidator unavailable — update FxTool to a build with the OntologyValidator' };`;
5046
+ if (input.ops) {
5047
+ const opsJson = JSON.stringify(input.ops);
5048
+ return `
5049
+ // Pre-validate proposed ops against the live scene
5050
+ (function() {
5051
+ ${guard}
5052
+ if (typeof v.validateOps !== 'function') return { success: false, error: 'validateOps unavailable in this FxTool build' };
5053
+ const r = v.validateOps(${opsJson});
5054
+ return { success: true, mode: 'ops', ok: r.ok, diagnosticCount: r.diagnostics.length, diagnostics: r.diagnostics };
5055
+ })();`.trim();
5056
+ }
5057
+ return `
5058
+ // Audit the current scene
5059
+ (function() {
5060
+ ${guard}
5061
+ const r = v.validateScene();
5062
+ return { success: true, mode: 'scene', ok: r.ok, diagnosticCount: r.diagnostics.length, diagnostics: r.diagnostics };
5063
+ })();`.trim();
5064
+ }
5065
+ /**
5066
+ * Deterministic headless frame capture (S3): app.captureFramesAt(times, { seed })
5067
+ * seeds Math.random once around the sequence and evaluates the scene at each time via
5068
+ * sceneAt(t). Returns a cheap per-frame hash by default (token-light) so an agent can
5069
+ * verify determinism (re-run → identical hashes) and whether frames actually change.
5070
+ */
5071
+ generateCaptureFrames(input) {
5072
+ const timesJson = JSON.stringify(input.times);
5073
+ const seed = input.seed !== undefined ? input.seed : 0;
5074
+ const includeDataUrls = !!input.includeDataUrls;
5075
+ return `
5076
+ // Deterministic frame capture
5077
+ (function() {
5078
+ if (typeof app.captureFramesAt !== 'function') {
5079
+ return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
5080
+ }
5081
+ const includeDataUrls = ${includeDataUrls};
5082
+ const hashStr = function(s) { let h = 5381; for (let i = 0; i < s.length; i++) { h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; } return h.toString(16); };
5083
+ const frames = app.captureFramesAt(${timesJson}, {
5084
+ seed: ${seed},
5085
+ capture: function(c, t, i) {
5086
+ // Prefer app.captureFrameDataURL(): it renders only the used region, so an
5087
+ // unbounded canvas's fill-workspace margin is never baked into the frame.
5088
+ // Fall back to the raw canvas toDataURL on pre-capture-region FxTool builds.
5089
+ const url = (typeof app.captureFrameDataURL === 'function')
5090
+ ? app.captureFrameDataURL()
5091
+ : ((c && c.toDataURL) ? c.toDataURL() : '');
5092
+ const f = { index: i, time: t, hash: hashStr(url), bytes: url.length };
5093
+ if (includeDataUrls) f.dataUrl = url;
5094
+ return f;
5095
+ }
5096
+ });
5097
+ const uniqueHashes = new Set(frames.map(function(f) { return f.hash; })).size;
5098
+ return { success: true, seed: ${seed}, frameCount: frames.length, uniqueHashes: uniqueHashes, allIdentical: uniqueHashes <= 1, frames: frames };
5099
+ })();`.trim();
5100
+ }
5101
+ /**
5102
+ * Ontology→scene compiler (S12-E3). Compiles the JSON-LD design graph SERVER-SIDE
5103
+ * (deterministic, unit-tested) into create/addRelation ops, then emits code that
5104
+ * applies them. Only root nodes carry coordinates; structural relations place the
5105
+ * rest at runtime. Compile diagnostics are baked into the emitted result.
5106
+ */
5107
+ generateInstantiateOntology(input) {
5108
+ const { ops, diagnostics } = new OntologyCompiler({
5109
+ canvas: input.canvas,
5110
+ defaultGeometry: input.defaultGeometry,
5111
+ }).compile(input.doc);
5112
+ const opsJson = JSON.stringify(ops, null, 2);
5113
+ const diagJson = JSON.stringify(diagnostics);
5114
+ return `
5115
+ // Instantiate ontology → scene (${ops.filter((o) => o.op === 'create').length} items, ${ops.filter((o) => o.op === 'addRelation').length} relations)
5116
+ (function() {
5117
+ if (typeof app.create !== 'function' || typeof app.addRelation !== 'function') {
5118
+ return { success: false, error: 'app.create / app.addRelation unavailable' };
5119
+ }
5120
+ const ops = ${opsJson};
5121
+ const diagnostics = ${diagJson};
5122
+ const itemIds = [];
5123
+ const errors = [];
5124
+ for (const op of ops) {
5125
+ try {
5126
+ if (op.op === 'create') {
5127
+ app.create(op.type, { id: op.id, x: op.x, y: op.y, width: op.width, height: op.height });
5128
+ itemIds.push(op.id);
5129
+ } else if (op.op === 'addRelation') {
5130
+ app.addRelation(op.from, op.to, op.relation, op.params || {});
5131
+ }
5132
+ } catch (e) {
5133
+ errors.push({ op: op.op, id: op.id || (op.from + '->' + op.to), error: e && e.message });
5134
+ }
5135
+ }
5136
+ if (app.historyManager) app.historyManager.saveState();
5137
+ return { success: errors.length === 0, itemIds: itemIds, itemCount: itemIds.length, diagnostics: diagnostics, errors: errors };
5138
+ })();`.trim();
5139
+ }
5140
+ /**
5141
+ * Relational-density lint of the live scene (S12-E2) via app.lintScene(). Reports
5142
+ * relationalDensity + edge/component counts and suggests structural relations for
5143
+ * unrelated item pairs already in a structural configuration. Read-only/advisory.
5144
+ */
5145
+ generateLintScene(input) {
5146
+ const optsJson = JSON.stringify({
5147
+ ...(input.eps !== undefined ? { eps: input.eps } : {}),
5148
+ ...(input.cap !== undefined ? { cap: input.cap } : {}),
5149
+ });
5150
+ return `
5151
+ // Relational-density lint (advisory)
5152
+ (function() {
5153
+ if (typeof app.lintScene !== 'function') {
5154
+ return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
5155
+ }
5156
+ const result = app.lintScene(${optsJson});
5157
+ return { success: true, density: result.density, suggestions: result.suggestions };
5158
+ })();`.trim();
5159
+ }
5160
+ /**
5161
+ * Media (video/audio) via window.PinePaperAgent. Upload is URL-based (the agent
5162
+ * hands a URL, FxTool fetches → File). Async IIFE so uploads resolve before the
5163
+ * value is returned (the governor awaits it).
5164
+ */
5165
+ generateMedia(input) {
5166
+ const guard = ` const A = (typeof window !== 'undefined') && window.PinePaperAgent;
5167
+ if (!A || typeof A.uploadVideo !== 'function') { return { success: false, error: 'window.PinePaperAgent media API unavailable — update FxTool to a media-capable build' }; }`;
5168
+ switch (input.action) {
5169
+ case 'upload_video': {
5170
+ const opts = JSON.stringify({
5171
+ ...(input.position ? { position: input.position } : {}),
5172
+ ...(input.scale !== undefined ? { scale: input.scale } : {}),
5173
+ ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
5174
+ ...(input.clipInPoint !== undefined ? { clipInPoint: input.clipInPoint } : {}),
5175
+ ...(input.clipOutPoint !== undefined ? { clipOutPoint: input.clipOutPoint } : {}),
5176
+ });
5177
+ return `
5178
+ // Upload video from URL
5179
+ (async function() {
5180
+ ${guard}
5181
+ const info = await A.uploadVideo(${JSON.stringify(input.url)}, ${opts});
5182
+ return { success: true, action: 'upload_video', media: info };
5183
+ })();`.trim();
5184
+ }
5185
+ case 'upload_audio': {
5186
+ const opts = JSON.stringify({
5187
+ ...(input.volume !== undefined ? { volume: input.volume } : {}),
5188
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
5189
+ ...(input.muted !== undefined ? { muted: input.muted } : {}),
5190
+ ...(input.timeOffset !== undefined ? { timeOffset: input.timeOffset } : {}),
5191
+ });
5192
+ return `
5193
+ // Upload audio from URL
5194
+ (async function() {
5195
+ ${guard}
5196
+ const info = await A.uploadAudio(${JSON.stringify(input.url)}, ${opts});
5197
+ return { success: true, action: 'upload_audio', media: info };
5198
+ })();`.trim();
5199
+ }
5200
+ case 'list':
5201
+ return `
5202
+ // List media
5203
+ (function() {
5204
+ ${guard}
5205
+ return { success: true, action: 'list', media: A.listMedia() };
5206
+ })();`.trim();
5207
+ case 'remove':
5208
+ return `
5209
+ // Remove media
5210
+ (function() {
5211
+ ${guard}
5212
+ const removed = A.removeMedia(${JSON.stringify(input.id)});
5213
+ return { success: removed, action: 'remove', id: ${JSON.stringify(input.id)}, removed: removed };
5214
+ })();`.trim();
5215
+ case 'set_playback_rate':
5216
+ return `
5217
+ // Set media playback rate
5218
+ (function() {
5219
+ ${guard}
5220
+ const ok = A.setMediaPlaybackRate(${JSON.stringify(input.id)}, ${input.rate});
5221
+ return { success: ok, action: 'set_playback_rate', id: ${JSON.stringify(input.id)}, rate: ${input.rate} };
5222
+ })();`.trim();
5223
+ case 'set_clip':
5224
+ return `
5225
+ // Re-trim an existing media clip (media-time seconds)
5226
+ (function() {
5227
+ ${guard}
5228
+ if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
5229
+ const ok = A.setMediaClip(${JSON.stringify(input.id)}, ${input.inPoint}, ${input.outPoint});
5230
+ return { success: ok, action: 'set_clip', id: ${JSON.stringify(input.id)}, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} };
5231
+ })();`.trim();
5232
+ }
5233
+ }
5234
+ /**
5235
+ * One-shot image crop via the PinePaper.cropImage facade (Track A parity).
5236
+ * The replacement raster keeps the item's registry id.
5237
+ */
5238
+ generateCropImage(input) {
5239
+ const opts = input.aspectRatio ? JSON.stringify({ aspectRatio: input.aspectRatio }) : '{}';
5240
+ return `
5241
+ // Crop image ${input.itemId}
5242
+ (async function() {
5243
+ if (typeof app.cropImage !== 'function') { return { success: false, error: 'app.cropImage unavailable — update FxTool to a build with one-shot image ops' }; }
5244
+ const result = await app.cropImage(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.rect)}, ${opts});
5245
+ if (!result) { return { success: false, error: 'crop failed — item is not a raster or rect does not intersect it' }; }
5246
+ return { success: true, itemId: result.data.id, width: Math.round(result.width), height: Math.round(result.height) };
5247
+ })();`.trim();
5248
+ }
5249
+ /**
5250
+ * One-shot chroma key (background removal) via PinePaper.applyChromaKey.
5251
+ * Threshold/smoothing auto-estimate when omitted.
5252
+ */
5253
+ generateChromaKey(input) {
5254
+ const opts = JSON.stringify({
5255
+ ...(input.color !== undefined ? { color: input.color } : {}),
5256
+ ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
5257
+ ...(input.smoothing !== undefined ? { smoothing: input.smoothing } : {}),
5258
+ });
5259
+ return `
5260
+ // Chroma-key image ${input.itemId}
5261
+ (async function() {
5262
+ if (typeof app.applyChromaKey !== 'function') { return { success: false, error: 'app.applyChromaKey unavailable — update FxTool to a build with one-shot image ops' }; }
5263
+ const result = await app.applyChromaKey(${JSON.stringify(input.itemId)}, ${opts});
5264
+ if (!result) { return { success: false, error: 'chroma key failed — item is not a raster or the color is invalid' }; }
5265
+ return { success: true, itemId: result.data.id, params: result.data.chromaKeyParams };
5266
+ })();`.trim();
5267
+ }
5268
+ /**
5269
+ * Rigging (skeletons/bones/IK/breakdown-pose keyframes) via app.riggingSystem.
5270
+ * One consolidated emitter; each action maps 1:1 to a riggingSystem method with
5271
+ * FxTool's exact param contract. Guarded on app.riggingSystem.
5272
+ */
5273
+ generateRigging(input) {
5274
+ const g = ` const R = app.riggingSystem;
5275
+ if (!R) { return { success: false, error: 'app.riggingSystem unavailable — update FxTool to a rigging-capable build' }; }`;
5276
+ const wrap = (comment, body) => `\n// ${comment}\n(function() {\n${g}\n${body}\n})();`.trim();
5277
+ const S = (v) => JSON.stringify(v);
5278
+ switch (input.action) {
5279
+ case 'create_skeleton': {
5280
+ const root = input.rootPosition ? S(input.rootPosition) : S({ x: 400, y: 300 });
5281
+ return wrap('Rigging: create skeleton', ` const skeletonId = R.createSkeleton(${S(input.name ?? null)}, ${root});
5282
+ if (app.historyManager) app.historyManager.saveState();
5283
+ return { success: !!skeletonId, action: 'create_skeleton', skeletonId: skeletonId };`);
5284
+ }
5285
+ case 'add_bone': {
5286
+ const config = S({
5287
+ ...(input.name !== undefined ? { name: input.name } : {}),
5288
+ ...(input.parentBoneId !== undefined ? { parentBoneId: input.parentBoneId } : {}),
5289
+ ...(input.length !== undefined ? { length: input.length } : {}),
5290
+ ...(input.angle !== undefined ? { angle: input.angle } : {}),
5291
+ ...(input.flexibility !== undefined ? { flexibility: input.flexibility } : {}),
5292
+ ...(input.segments !== undefined ? { segments: input.segments } : {}),
5293
+ });
5294
+ return wrap('Rigging: add bone', ` const boneId = R.addBone(${S(input.skeletonId)}, ${config});
5295
+ if (app.historyManager) app.historyManager.saveState();
5296
+ return { success: !!boneId, action: 'add_bone', boneId: boneId };`);
5297
+ }
5298
+ case 'attach_item': {
5299
+ const opts = S(input.attachPoint !== undefined ? { attachPoint: input.attachPoint } : {});
5300
+ return wrap('Rigging: attach item to bone', ` const ok = R.attachItem(${S(input.skeletonId)}, ${S(input.boneId)}, ${S(input.itemId)}, ${opts});
5301
+ if (app.historyManager) app.historyManager.saveState();
5302
+ return { success: !!ok, action: 'attach_item', itemId: ${S(input.itemId)}, boneId: ${S(input.boneId)} };`);
5303
+ }
5304
+ case 'create_ik_chain': {
5305
+ const config = S({
5306
+ boneIds: input.boneIds,
5307
+ ...(input.name !== undefined ? { name: input.name } : {}),
5308
+ ...(input.solverType !== undefined ? { solverType: input.solverType } : {}),
5309
+ ...(input.iterations !== undefined ? { iterations: input.iterations } : {}),
5310
+ ...(input.tolerance !== undefined ? { tolerance: input.tolerance } : {}),
5311
+ ...(input.strength !== undefined ? { strength: input.strength } : {}),
5312
+ ...(input.poleVector !== undefined ? { poleVector: input.poleVector } : {}),
5313
+ });
5314
+ return wrap('Rigging: create IK chain', ` const chainId = R.createIKChain(${S(input.skeletonId)}, ${config});
5315
+ if (app.historyManager) app.historyManager.saveState();
5316
+ return { success: !!chainId, action: 'create_ik_chain', chainId: chainId };`);
5317
+ }
5318
+ case 'add_pose_keyframe': {
5319
+ const opts = S({
5320
+ ...(input.favor !== undefined ? { favor: input.favor } : {}),
5321
+ ...(input.breakdown !== undefined ? { breakdown: input.breakdown } : {}),
5322
+ ...(input.curve !== undefined ? { curve: input.curve } : {}),
5323
+ ...(input.boneOffsets !== undefined ? { boneOffsets: input.boneOffsets } : {}),
5324
+ ...(input.movingHold !== undefined ? { movingHold: input.movingHold } : {}),
5325
+ ...(input.holdDrift !== undefined ? { holdDrift: input.holdDrift } : {}),
5326
+ });
5327
+ return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
5328
+ if (app.historyManager) app.historyManager.saveState();
5329
+ return { success: !!ok, action: 'add_pose_keyframe', time: ${input.time} };`);
5330
+ }
5331
+ case 'set_target_path': {
5332
+ const opts = S({
5333
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
5334
+ ...(input.loop !== undefined ? { loop: input.loop } : {}),
5335
+ });
5336
+ return wrap('Rigging: set IK target path', ` const ok = R.setTargetPath(${S(input.skeletonId)}, ${S(input.chainId)}, ${S(input.waypoints)}, ${opts});
5337
+ if (app.historyManager) app.historyManager.saveState();
5338
+ return { success: !!ok, action: 'set_target_path', chainId: ${S(input.chainId)} };`);
5339
+ }
5340
+ case 'save_pose':
5341
+ return wrap('Rigging: save pose', ` const poseId = R.savePose(${S(input.skeletonId)}, ${S(input.name ?? null)});
5342
+ if (app.historyManager) app.historyManager.saveState();
5343
+ return { success: !!poseId, action: 'save_pose', poseId: poseId };`);
5344
+ case 'save_shape_key':
5345
+ return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
5346
+ if (app.historyManager) app.historyManager.saveState();
5347
+ return { success: !!shapeKeyId, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
5348
+ }
5349
+ }
5350
+ /**
5351
+ * Drive app.constructionSequence (Layer 3): build a step-by-step reveal of a
5352
+ * figure, play it on the timeline, clear it, or list sequences. Item refs are
5353
+ * registryId strings — build resolves them via app.getItemById.
5354
+ */
5355
+ generateConstructionSequence(input) {
5356
+ const guard = `const cs = app.constructionSequence;
5357
+ if (!cs) return { success: false, error: 'app.constructionSequence unavailable — update FxTool to a build with the construction-sequence library' };`;
5358
+ switch (input.action) {
5359
+ case 'build': {
5360
+ const stepsJson = JSON.stringify(input.steps ?? []);
5361
+ const opts = JSON.stringify({
5362
+ ...(input.stepDuration !== undefined ? { stepDuration: input.stepDuration } : {}),
5363
+ ...(input.fadeIn !== undefined ? { fadeIn: input.fadeIn } : {}),
5364
+ });
5365
+ return `
5366
+ // Build construction sequence
5367
+ (function() {
5368
+ ${guard}
5369
+ const rec = cs.build(${stepsJson}, ${opts});
5370
+ return {
5371
+ success: true,
5372
+ action: 'build',
5373
+ sequenceId: rec.id,
5374
+ stepCount: rec.steps.length,
5375
+ stepDuration: rec.stepDuration,
5376
+ fadeIn: rec.fadeIn,
5377
+ totalDuration: rec.totalDuration,
5378
+ };
5379
+ })();`.trim();
5380
+ }
5381
+ case 'play': {
5382
+ const seqArg = input.sequenceId ? JSON.stringify(input.sequenceId) : 'null';
5383
+ const opts = JSON.stringify({
5384
+ loop: !!input.loop,
5385
+ ...(input.duration !== undefined ? { duration: input.duration } : {}),
5386
+ });
5387
+ return `
5388
+ // Play construction sequence
5389
+ (function() {
5390
+ ${guard}
5391
+ let seqId = ${seqArg};
5392
+ if (!seqId) { const all = cs.list(); seqId = all.length ? all[all.length - 1].id : null; }
5393
+ if (!seqId) return { success: false, error: 'No sequence to play — build one first' };
5394
+ const duration = cs.play(seqId, ${opts});
5395
+ return { success: true, action: 'play', sequenceId: seqId, duration: duration, loop: ${!!input.loop} };
5396
+ })();`.trim();
5397
+ }
5398
+ case 'clear': {
5399
+ const seqArg = input.sequenceId ? JSON.stringify(input.sequenceId) : 'null';
5400
+ return `
5401
+ // Clear construction sequence
5402
+ (function() {
5403
+ ${guard}
5404
+ let seqId = ${seqArg};
5405
+ if (!seqId) { const all = cs.list(); seqId = all.length ? all[all.length - 1].id : null; }
5406
+ if (!seqId) return { success: false, error: 'No sequence to clear' };
5407
+ const ok = cs.clear(seqId);
5408
+ return { success: ok, action: 'clear', sequenceId: seqId };
5409
+ })();`.trim();
5410
+ }
5411
+ case 'list':
5412
+ default:
5413
+ return `
5414
+ // List construction sequences
5415
+ (function() {
5416
+ ${guard}
5417
+ const sequences = cs.list().map(function(r) {
5418
+ return { id: r.id, stepCount: r.steps.length, stepDuration: r.stepDuration, totalDuration: r.totalDuration };
5419
+ });
5420
+ return { success: true, action: 'list', sequences: sequences };
5421
+ })();`.trim();
5422
+ }
5423
+ }
5424
+ /**
5425
+ * Run an app.geometry.* construction helper (Layer 1) and, when createAs is
5426
+ * given, build a canvas item from the result. The op name is whitelisted by the
5427
+ * Zod enum, so it is safe to index app.geometry[op] directly. Mirrors FxTool's
5428
+ * documented composition: app.create('polygon', { points: app.geometry.regularPolygon(...) }).
5429
+ */
5430
+ generateGeometry(input) {
5431
+ const op = JSON.stringify(input.operation);
5432
+ const argsJson = JSON.stringify(input.args ?? []);
5433
+ const createAsJson = input.createAs ? JSON.stringify(input.createAs) : 'null';
5434
+ return `
5435
+ // Geometry construction: ${input.operation}
5436
+ (function() {
5437
+ const g = app.geometry;
5438
+ if (!g || typeof g[${op}] !== 'function') {
5439
+ return { success: false, error: 'app.geometry.' + ${op} + ' unavailable — update FxTool to a build with the geometry construction library' };
5440
+ }
5441
+ const result = g[${op}](...${argsJson});
5442
+ if (result == null) {
5443
+ return { success: false, operation: ${op}, result: null, error: 'Degenerate construction (parallel lines, collinear points, or a point inside the circle) — returned null' };
5444
+ }
5445
+
5446
+ let itemId = null;
5447
+ const createAs = ${createAsJson};
5448
+ if (createAs) {
5449
+ const { itemType, radius, ...style } = createAs;
5450
+ let item = null;
5451
+ if (Array.isArray(result)) {
5452
+ // Vertex list (regularPolygon / star / polygonFromVertices / tangent points)
5453
+ item = app.create('polygon', { points: result, ...style });
5454
+ } else if (result.center && typeof result.radius === 'number') {
5455
+ // Circle / circumcircle
5456
+ item = app.create('circle', { x: result.center.x, y: result.center.y, radius: result.radius, ...style });
5457
+ } else if (typeof result.x === 'number' && typeof result.y === 'number') {
5458
+ // Point → small marker
5459
+ item = app.create(itemType || 'circle', { x: result.x, y: result.y, radius: (radius != null ? radius : 6), ...style });
5460
+ } else {
5461
+ return { success: true, operation: ${op}, result, itemId: null, note: 'Result is not directly creatable (line/scalar) — use the returned geometry as input to other tools' };
5462
+ }
5463
+ if (item) {
5464
+ if (item.bringToFront) item.bringToFront();
5465
+ itemId = item.data && item.data.registryId;
5466
+ if (app.historyManager) app.historyManager.saveState();
5467
+ }
5468
+ }
5469
+
5470
+ return { success: true, operation: ${op}, result, itemId };
5471
+ })();`.trim();
5472
+ }
5473
+ /**
5474
+ * Generate code for an equation-driven path (Expression IR — S10 B5).
5475
+ * Emits app.createEquationPath(opts); the whole validated input IS the opts
5476
+ * object. Guards on the app method so old FxTool builds degrade gracefully.
5477
+ */
5478
+ generateEquationPath(input) {
5479
+ const optsJson = JSON.stringify(input);
5480
+ const kindJson = JSON.stringify(input.kind);
5481
+ return `
5482
+ // Equation-driven path: ${input.kind}${input.preset ? ' ' + input.preset : ''}
5483
+ (function() {
5484
+ if (typeof app.createEquationPath !== 'function') {
5485
+ return { success: false, error: 'app.createEquationPath unavailable — update FxTool to a build with the Expression IR equation-path API (S10 B5)' };
5486
+ }
5487
+ const path = app.createEquationPath(${optsJson});
5488
+ if (!path) {
5489
+ return { success: false, error: 'Equation path produced no points — check expr/xExpr/yExpr, the sample range, or the preset name' };
5490
+ }
5491
+ const itemId = path.data && path.data.registryId;
5492
+ if (app.historyManager) app.historyManager.saveState();
5493
+ return { success: true, kind: ${kindJson}, itemId };
5494
+ })();`.trim();
5495
+ }
5496
+ /**
5497
+ * Generate code for the pp:event channel (S11 event-driven scene chains).
5498
+ * create → app.createEvent(name, opts) → eventId; pulse → app.pulseEvent.
5499
+ * Guards on the app methods so old FxTool builds degrade gracefully.
5500
+ */
5501
+ generateEvent(input) {
5502
+ if (input.action === 'create') {
5503
+ const nameJson = JSON.stringify(input.name);
5504
+ const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y });
5505
+ return `
5506
+ // Create event: ${input.name}
5507
+ (function() {
5508
+ if (typeof app.createEvent !== 'function') {
5509
+ return { success: false, error: 'app.createEvent unavailable — update FxTool to a build with the event system (S11)' };
5510
+ }
5511
+ const eventId = app.createEvent(${nameJson}, ${optsJson});
5512
+ if (!eventId) { return { success: false, error: 'Event creation failed' }; }
5513
+ if (app.historyManager) app.historyManager.saveState();
5514
+ return { success: true, action: 'create', eventId };
5515
+ })();`.trim();
5516
+ }
5517
+ // pulse — a runtime fire, not a scene edit (no history save)
5518
+ const eventIdJson = JSON.stringify(input.eventId);
5519
+ const payloadJson = JSON.stringify(input.payload ?? null);
5520
+ return `
5521
+ // Pulse event: ${input.eventId}
5522
+ (function() {
5523
+ if (typeof app.pulseEvent !== 'function') {
5524
+ return { success: false, error: 'app.pulseEvent unavailable — update FxTool to a build with the event system (S11)' };
5525
+ }
5526
+ app.pulseEvent(${eventIdJson}, ${payloadJson});
5527
+ return { success: true, action: 'pulse', eventId: ${eventIdJson} };
5528
+ })();`.trim();
5529
+ }
4885
5530
  }
4886
5531
  // Export singleton instance
4887
5532
  export const codeGenerator = new PinePaperCodeGenerator();