@pinepaper.studio/mcp-server 1.6.22 → 1.6.24

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 (110) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +115 -5
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/SECURITY.md +46 -0
  11. package/dist/agent/types.d.ts +114 -106
  12. package/dist/agent/types.d.ts.map +1 -1
  13. package/dist/agent/types.js +1 -1
  14. package/dist/agent/types.js.map +1 -1
  15. package/dist/browser/puppeteer-controller.js +2 -2
  16. package/dist/browser/puppeteer-controller.js.map +1 -1
  17. package/dist/build-info.json +2 -2
  18. package/dist/design/design-systems.d.ts +50 -0
  19. package/dist/design/design-systems.d.ts.map +1 -1
  20. package/dist/design/design-systems.js +220 -35
  21. package/dist/design/design-systems.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +13 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/ontology/hints.d.ts.map +1 -1
  26. package/dist/ontology/hints.js +5 -1
  27. package/dist/ontology/hints.js.map +1 -1
  28. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  29. package/dist/ontology/upstream-vocabulary.js +1 -1
  30. package/dist/ontology/vocabulary.d.ts.map +1 -1
  31. package/dist/ontology/vocabulary.js +1 -0
  32. package/dist/ontology/vocabulary.js.map +1 -1
  33. package/dist/tools/batch-macros.d.ts +27 -0
  34. package/dist/tools/batch-macros.d.ts.map +1 -0
  35. package/dist/tools/batch-macros.js +157 -0
  36. package/dist/tools/batch-macros.js.map +1 -0
  37. package/dist/tools/choreograph.d.ts +4 -0
  38. package/dist/tools/choreograph.d.ts.map +1 -1
  39. package/dist/tools/choreograph.js +12 -2
  40. package/dist/tools/choreograph.js.map +1 -1
  41. package/dist/tools/compact-descriptions.js +3 -3
  42. package/dist/tools/compact-descriptions.js.map +1 -1
  43. package/dist/tools/definitions.d.ts.map +1 -1
  44. package/dist/tools/definitions.js +122 -15
  45. package/dist/tools/definitions.js.map +1 -1
  46. package/dist/tools/engine-lists.d.ts +2 -2
  47. package/dist/tools/engine-lists.js +2 -2
  48. package/dist/tools/engine-methods.d.ts.map +1 -1
  49. package/dist/tools/engine-methods.js +3 -0
  50. package/dist/tools/engine-methods.js.map +1 -1
  51. package/dist/tools/engine-surface.d.ts +1 -1
  52. package/dist/tools/engine-surface.d.ts.map +1 -1
  53. package/dist/tools/engine-surface.js +52 -6
  54. package/dist/tools/engine-surface.js.map +1 -1
  55. package/dist/tools/export-file-check.d.ts +31 -0
  56. package/dist/tools/export-file-check.d.ts.map +1 -0
  57. package/dist/tools/export-file-check.js +103 -0
  58. package/dist/tools/export-file-check.js.map +1 -0
  59. package/dist/tools/export-file-receipt.d.ts +56 -0
  60. package/dist/tools/export-file-receipt.d.ts.map +1 -0
  61. package/dist/tools/export-file-receipt.js +151 -0
  62. package/dist/tools/export-file-receipt.js.map +1 -0
  63. package/dist/tools/handlers/beats.d.ts +7 -44
  64. package/dist/tools/handlers/beats.d.ts.map +1 -1
  65. package/dist/tools/handlers/export.d.ts.map +1 -1
  66. package/dist/tools/handlers/export.js +31 -6
  67. package/dist/tools/handlers/export.js.map +1 -1
  68. package/dist/tools/handlers/generate.d.ts +41 -270
  69. package/dist/tools/handlers/generate.d.ts.map +1 -1
  70. package/dist/tools/handlers.d.ts +84 -1
  71. package/dist/tools/handlers.d.ts.map +1 -1
  72. package/dist/tools/handlers.js +389 -82
  73. package/dist/tools/handlers.js.map +1 -1
  74. package/dist/tools/lane.d.ts +34 -0
  75. package/dist/tools/lane.d.ts.map +1 -0
  76. package/dist/tools/lane.js +210 -0
  77. package/dist/tools/lane.js.map +1 -0
  78. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  79. package/dist/tools/minimal-descriptions.js +3 -2
  80. package/dist/tools/minimal-descriptions.js.map +1 -1
  81. package/dist/tools/nested-ignored.d.ts +16 -0
  82. package/dist/tools/nested-ignored.d.ts.map +1 -0
  83. package/dist/tools/nested-ignored.js +83 -0
  84. package/dist/tools/nested-ignored.js.map +1 -0
  85. package/dist/tools/shape-params.d.ts +2 -2
  86. package/dist/tools/shape-params.d.ts.map +1 -1
  87. package/dist/tools/shape-params.js +9 -2
  88. package/dist/tools/shape-params.js.map +1 -1
  89. package/dist/tools/stick-vocabulary.d.ts +4 -4
  90. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  91. package/dist/tools/stick-vocabulary.js +8 -4
  92. package/dist/tools/stick-vocabulary.js.map +1 -1
  93. package/dist/tools/timeline.d.ts +136 -0
  94. package/dist/tools/timeline.d.ts.map +1 -0
  95. package/dist/tools/timeline.js +1036 -0
  96. package/dist/tools/timeline.js.map +1 -0
  97. package/dist/tools/toolkits.d.ts +1 -1
  98. package/dist/tools/toolkits.d.ts.map +1 -1
  99. package/dist/tools/toolkits.js +12 -2
  100. package/dist/tools/toolkits.js.map +1 -1
  101. package/dist/types/code-generator.d.ts +66 -3
  102. package/dist/types/code-generator.d.ts.map +1 -1
  103. package/dist/types/code-generator.js +629 -96
  104. package/dist/types/code-generator.js.map +1 -1
  105. package/dist/types/schemas.d.ts +5278 -24999
  106. package/dist/types/schemas.d.ts.map +1 -1
  107. package/dist/types/schemas.js +93 -40
  108. package/dist/types/schemas.js.map +1 -1
  109. package/manifest.json +5 -1
  110. package/package.json +8 -7
@@ -5,11 +5,13 @@
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
7
  import { compileChoreography } from '../tools/choreograph.js';
8
+ import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
8
9
  import { normalizeSvgSize } from '../utils/svg-size.js';
9
10
  import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
10
11
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
11
12
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
12
13
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
14
+ import { expandBatchMacros } from '../tools/batch-macros.js';
13
15
  import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
14
16
  // Diagram types
15
17
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
@@ -26,7 +28,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
26
28
  // Custom relation/code types
27
29
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
28
30
  // Mask types
29
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
31
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, PatternInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
30
32
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
31
33
  // =============================================================================
32
34
  // HELPER FUNCTIONS
@@ -74,17 +76,43 @@ function normalizeGradientSpec(v, key) {
74
76
  */
75
77
  function generateGradientCode(gradient, itemVarName, position, propertyName = 'fillColor') {
76
78
  const { type, stops, origin, destination } = gradient;
77
- // Calculate default origin/destination based on item position.
78
- // For radial gradients, origin defaults to the item center (position.x, position.y),
79
- // not an offset (-50, -50), and destination to the radius edge (B88).
80
- const defaultOrigin = origin || (type === 'radial' ? [position.x, position.y] : [position.x - 50, position.y - 50]);
81
- const defaultDestination = destination || (type === 'radial' ? [position.x + 50, position.y] : [position.x + 50, position.y + 50]);
82
79
  const stopsCode = stops.map(s => `['${s.color}', ${s.offset}]`).join(', ');
80
+ if (type === 'radial') {
81
+ // A RADIAL CORE BELONGS ON THE ITEM, NOT ON THE NUMBER ASKED FOR (17
82
+ // RADIALOFFSET). The origin was the REQUESTED position and the radius a
83
+ // fixed 50 px: whenever (x, y) placed the item as a corner (an anchor, a
84
+ // default position) the core landed up-left by half the item, and a 300 px
85
+ // circle got a 50 px core. Read from the created item's bounds instead —
86
+ // centre and right-centre, as the engine's own _parseColor does — with the
87
+ // request only as the fallback when the item has no bounds. An origin or
88
+ // destination the caller gave is kept as given.
89
+ const fx = position.x, fy = position.y;
90
+ const o = origin ? `[${origin[0]}, ${origin[1]}]` : `(__gb ? [__gb.x + __gb.width / 2, __gb.y + __gb.height / 2] : [${fx}, ${fy}])`;
91
+ const d = destination ? `[${destination[0]}, ${destination[1]}]`
92
+ : origin ? `(__gb ? [${origin[0]} + __gb.width / 2, ${origin[1]}] : [${origin[0]} + 50, ${origin[1]}])`
93
+ : `(__gb ? [__gb.x + __gb.width, __gb.y + __gb.height / 2] : [${fx + 50}, ${fy}])`;
94
+ return `
95
+ ${itemVarName}.${propertyName} = (function() {
96
+ const __b = ${itemVarName} && ${itemVarName}.bounds;
97
+ const __gb = __b && isFinite(__b.x) && isFinite(__b.y) && __b.width > 0 && __b.height > 0 ? __b : null;
98
+ return {
99
+ gradient: {
100
+ stops: [${stopsCode}],
101
+ radial: true
102
+ },
103
+ origin: ${o},
104
+ destination: ${d}
105
+ };
106
+ })();`;
107
+ }
108
+ // Linear: a diagonal across a 100 px box at the requested position.
109
+ const defaultOrigin = origin || [position.x - 50, position.y - 50];
110
+ const defaultDestination = destination || [position.x + 50, position.y + 50];
83
111
  return `
84
112
  ${itemVarName}.${propertyName} = {
85
113
  gradient: {
86
114
  stops: [${stopsCode}],
87
- radial: ${type === 'radial'}
115
+ radial: false
88
116
  },
89
117
  origin: [${defaultOrigin[0]}, ${defaultOrigin[1]}],
90
118
  destination: [${defaultDestination[0]}, ${defaultDestination[1]}]
@@ -172,6 +200,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
172
200
  textDirection.resolved = baseDirection(createProps.content);
173
201
  }
174
202
  const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
203
+ const createFillOpacity = createProps.fillOpacity !== undefined;
175
204
  const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
176
205
  const params = {
177
206
  ...(omitXY ? {} : { x: position.x, y: position.y }),
@@ -282,7 +311,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
282
311
  const textOnly = (k) => TEXT_STYLE_KEYS.includes(k) || k === 'fit' || k === 'textDirection' || k === 'dir'
283
312
  || TABULAR_KEYS.includes(k) || k === 'strokePosition';
284
313
  const ignored = Object.keys(properties).filter((k) => itemType === 'text'
285
- ? !CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k)
314
+ ? (!CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k))
315
+ || (k === 'letterSpacing' && letterSpacingCss(properties[k]) === undefined)
286
316
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
287
317
  // Build the code
288
318
  let code = `
@@ -337,6 +367,8 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
337
367
  code += emitDirectionCheck('item', textDirection);
338
368
  if (textTabular)
339
369
  code += emitTabularCheck('item');
370
+ if (createFillOpacity)
371
+ code += emitFillOpacityCheck('item');
340
372
  if (textStrokePos)
341
373
  code += emitStrokePositionCheck('item', textStrokePos);
342
374
  if (textFit)
@@ -374,7 +406,7 @@ const itemId = item.data.registryId;
374
406
  app.historyManager.saveState();
375
407
 
376
408
  // Return item info
377
- ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
409
+ ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
378
410
  ? `, ignoredProperties: ${JSON.stringify(ignored)}, warning: ${JSON.stringify(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
379
411
  + 'Check the spelling against this item type\'s documented properties.')}`
380
412
  : ''} });`;
@@ -398,7 +430,7 @@ const MODIFY_KNOWN_KEYS = new Set([
398
430
  ...NORMALIZE_PARAM_READS,
399
431
  'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
400
432
  'audioGain', 'volume', 'gain', 'bornAt', 'ttl', 'smoothing',
401
- 'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition',
433
+ 'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition', 'fillOpacity',
402
434
  ]);
403
435
  /** Spellings callers reach for, and the key the engine actually reads. */
404
436
  const MODIFY_DID_YOU_MEAN = {
@@ -419,6 +451,9 @@ const CREATE_KNOWN_KEYS = new Set([
419
451
  // Lifetimes, on ANY item: this generator applies them through app.setLifetime
420
452
  // (emitLifetime), whatever create() itself reads.
421
453
  'bornAt', 'ttl',
454
+ // A solid fill's own alpha (FxTool 12 STROKEFILL); confirmed per call by
455
+ // emitFillOpacityCheck, since an older studio ignores it.
456
+ 'fillOpacity',
422
457
  ]);
423
458
  /**
424
459
  * Load a font family before text is drawn with it, and say if it still is not there.
@@ -566,15 +601,61 @@ function wantsTabular(p) {
566
601
  const list = Array.isArray(f) ? f.map(String) : typeof f === 'string' ? [f] : f && typeof f === 'object' ? Object.keys(f).filter((k) => f[k]) : [];
567
602
  return list.some((x) => /\btnum\b/.test(x));
568
603
  }
604
+ /**
605
+ * Whether tabular figures CHANGED the layout is the engine's to measure: the
606
+ * request flag (data.tabularFigures) is set even when the font's digits are
607
+ * already one width, where the layout moves nothing (9 TABNUM: DejaVu Sans
608
+ * reported applied while every advance matched the control). A studio with
609
+ * app.tabularFiguresReport measures the item's own font, and only its answer
610
+ * is reported as applied. One without it can only say the flag was recorded:
611
+ * 'unverified', never true.
612
+ */
569
613
  function emitTabularCheck(itemExpr) {
570
614
  return `
571
615
  const __tabular = (function(it) {
572
616
  if (!it || it.className !== 'PointText') return { applied: false, note: 'tabular figures apply to text items only.' };
617
+ if (typeof app.tabularFiguresReport === 'function') {
618
+ let r = null;
619
+ try { r = app.tabularFiguresReport(it); } catch (e) { return { applied: 'unverified', note: 'the studio could not measure tabular figures on this text (' + ((e && e.message) || e) + ').' }; }
620
+ if (r && typeof r === 'object') {
621
+ const out = { applied: r.applied === true };
622
+ if (typeof r.steadyWidth === 'boolean') out.steadyWidth = r.steadyWidth;
623
+ if (r.reason) out.reason = r.reason;
624
+ if (r.note) out.note = r.note;
625
+ else if (r.reason === 'rtl') out.note = 'right-to-left text keeps proportional digits.';
626
+ else if (r.reason === 'not-requested') out.note = 'the studio did not record the request, so digits keep their proportional widths.';
627
+ if (typeof r.spread === 'number') out.digitSpread = r.spread;
628
+ if (r.width && typeof r.width === 'object') out.width = r.width;
629
+ return out;
630
+ }
631
+ return { applied: 'unverified', note: 'the studio returned no tabular-figures report for this text.' };
632
+ }
573
633
  return it.data && it.data.tabularFigures === true
574
- ? { applied: true }
634
+ ? { applied: 'unverified', note: 'this studio recorded tabular figures but cannot measure whether they change the layout (it has no tabularFiguresReport): with a font whose digits are already one width they change nothing.' }
575
635
  : { applied: false, note: 'this studio does not lay out tabular figures (it needs an engine with them): digits keep their proportional widths. A monospaced font is the fallback.' };
576
636
  })(${itemExpr});`;
577
637
  }
638
+ /**
639
+ * fillOpacity (FxTool 12 STROKEFILL): a solid fill's own alpha, apart from the
640
+ * item's opacity and its stroke, so an outline-only type is fillOpacity: 0.
641
+ * The engine records it on data.fillOpacity and re-applies it when a later
642
+ * modify changes the fill colour. A studio without it ignores the key, so the
643
+ * reply is read from the item: the recorded value and the fill's alpha.
644
+ */
645
+ function emitFillOpacityCheck(itemExpr) {
646
+ return `
647
+ const __fillOpacity = (function(it) {
648
+ if (!it) return { applied: false, note: 'no item to check.' };
649
+ const v = it.data && it.data.fillOpacity;
650
+ if (typeof v !== 'number') return { applied: false, note: 'this studio does not read fillOpacity (it needs an engine with it): the fill keeps its own alpha. opacity fades the whole item, stroke included; an rgba() fill sets the fill alone.' };
651
+ const c = it.fillColor;
652
+ if (!c) return { applied: true, value: v, note: 'the item has no fill now, so there is nothing to see; a fill colour set later with modify_item takes this alpha.' };
653
+ if (c.gradient) return { applied: false, value: v, note: 'a gradient fill keeps its own stop colours: put the alpha in the stops (rgba) instead.' };
654
+ return (typeof c.alpha === 'number' && Math.abs(c.alpha - v) > 0.01)
655
+ ? { applied: false, value: v, note: 'the studio recorded fillOpacity but the fill is drawn at alpha ' + c.alpha + '.' }
656
+ : { applied: true, value: v };
657
+ })(${itemExpr});`;
658
+ }
578
659
  function takeStrokePosition(props, allowNull) {
579
660
  if (!('strokePosition' in props))
580
661
  return null;
@@ -656,13 +737,26 @@ function authoringFrameJs(name) {
656
737
  return (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || cs || { width: 1920, height: 1080 };
657
738
  })();`;
658
739
  }
659
- const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'textAlign', 'align', 'text'];
740
+ const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'letterSpacing', 'textAlign', 'align', 'text'];
741
+ /**
742
+ * letterSpacing as the studio draws it, or undefined if it cannot be. create()
743
+ * and modifyItem() do not read it (B108); the studio draws the item's own
744
+ * letterSpacing, a CSS length, as its keyframes set it, and a number is px.
745
+ */
746
+ function letterSpacingCss(v) {
747
+ if (typeof v === 'number' && Number.isFinite(v))
748
+ return `${v}px`;
749
+ if (typeof v === 'string' && /^-?\d*\.?\d+(px|em)?$/.test(v.trim()))
750
+ return /\d$/.test(v.trim()) ? `${v.trim()}px` : v.trim();
751
+ return undefined;
752
+ }
660
753
  function emitTextStyle(itemExpr, props) {
661
754
  const weight = props.fontWeight;
662
755
  const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
663
756
  const lh = typeof props.lineHeight === 'number' ? props.lineHeight : undefined;
664
757
  const leading = typeof props.leading === 'number' ? props.leading : undefined;
665
- if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined)
758
+ const ls = letterSpacingCss(props.letterSpacing);
759
+ if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined && ls === undefined)
666
760
  return '';
667
761
  return `
668
762
  (function(it) {
@@ -675,6 +769,7 @@ function emitTextStyle(itemExpr, props) {
675
769
  : ''}
676
770
  ${leading !== undefined ? `it.leading = ${leading};` : ''}
677
771
  ${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
772
+ ${ls !== undefined ? `it.letterSpacing = ${JSON.stringify(ls)};` : ''}
678
773
  const b1 = it.bounds;
679
774
  it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
680
775
  })(${itemExpr});`;
@@ -853,6 +948,7 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
853
948
  delete restProperties.fit;
854
949
  const modDirection = takeDirection(restProperties);
855
950
  const modTabular = wantsTabular(restProperties) === true;
951
+ const modFillOpacity = restProperties.fillOpacity !== undefined;
856
952
  const modStrokePos = takeStrokePosition(restProperties, true);
857
953
  // modifyItem(id, changes), NOT select() + modify().
858
954
  //
@@ -980,6 +1076,8 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
980
1076
  // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
981
1077
  // raster answered success and left it 16 px; the engine reads `scale`. Same
982
1078
  // report create_item gives, against the engine's modify path (generated).
1079
+ if (textStyle && letterSpacingCss(properties.letterSpacing) !== undefined)
1080
+ aliased.add('letterSpacing');
983
1081
  const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
984
1082
  const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
985
1083
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
@@ -992,8 +1090,8 @@ ${properties.content !== undefined ? `
992
1090
  const __ctItem = app.getItemById('${itemId}');
993
1091
  const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
994
1092
  ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
995
- ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
996
- return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
1093
+ ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modFillOpacity ? emitFillOpacityCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
1094
+ return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
997
1095
  // Wrapped, because the body now RETURNS early on a miss rather than falling
998
1096
  // through to an unconditional success. Async only when a font must load
999
1097
  // before modifyItem re-measures the text.
@@ -1035,6 +1133,15 @@ function generateDeleteItemCode(itemId) {
1035
1133
  /**
1036
1134
  * Template for adding relations
1037
1135
  */
1136
+ /**
1137
+ * A JS expression: the studio's own reason for refusing a relation of `typeJs`,
1138
+ * or null. addRelation answers a bare false; the WHY (B119: "relation
1139
+ * 'spring_follow' is not loaded yet…") is recorded with app.noteWarning, which
1140
+ * reaches the reply only as a governor warning. Read here without draining it.
1141
+ */
1142
+ export function relationRefusalReasonJs(typeJs) {
1143
+ return `(function(){ var ws = (app && app._engineWarnings) || []; for (var i = ws.length - 1; i >= 0; i--) { var w = ws[i]; if (w && w.relation === ${typeJs} && typeof w.message === 'string') return w.message; } return null; })()`;
1144
+ }
1038
1145
  function generateAddRelationCode(sourceId, targetId, relationType, params) {
1039
1146
  // AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
1040
1147
  // stores a relation to an id it cannot resolve, returns true, and says "it
@@ -1055,13 +1162,109 @@ function generateAddRelationCode(sourceId, targetId, relationType, params) {
1055
1162
  }
1056
1163
  const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
1057
1164
  if (!success) {
1058
- return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}.' };
1165
+ const __why = ${relationRefusalReasonJs(JSON.stringify(relationType))};
1166
+ return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}' + (__why ? ': ' + __why : '.') };
1059
1167
  }
1060
1168
  app.historyManager.saveState();
1061
1169
  return { success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' };
1062
1170
  })();
1063
1171
  `.trim();
1064
1172
  }
1173
+ /**
1174
+ * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
1175
+ * MAX_BURST_COUNT, B144). Studios before that change capped at
1176
+ * EMITTER_LEGACY_MAX_PER_BURST, in silence. Which one a studio has is read
1177
+ * back from the spec it stored (generateEmitter), not assumed.
1178
+ */
1179
+ export const EMITTER_MAX_PER_BURST = 10000;
1180
+ /** The cap of studios before B144 (normalizeLook clamped at 2000 without a word). */
1181
+ export const EMITTER_LEGACY_MAX_PER_BURST = 2000;
1182
+ /**
1183
+ * Every count in an emitter spec (the spec's own and each burst's) that some
1184
+ * studio may cap: above the legacy cap. `index` is null for the spec's own
1185
+ * count (each burst without its own inherits it).
1186
+ */
1187
+ export function emitterCountsToCheck(spec) {
1188
+ const out = [];
1189
+ if (!spec || typeof spec !== 'object')
1190
+ return out;
1191
+ const capped = (v) => typeof v === 'number' && Number.isFinite(v) && Math.round(v) > EMITTER_LEGACY_MAX_PER_BURST;
1192
+ if (capped(spec.count))
1193
+ out.push({ where: 'count', index: null, asked: Math.round(spec.count) });
1194
+ if (Array.isArray(spec.bursts)) {
1195
+ spec.bursts.forEach((b, i) => {
1196
+ const c = b && typeof b === 'object' ? b.count : undefined;
1197
+ if (capped(c))
1198
+ out.push({ where: `bursts[${i}].count`, index: i, asked: Math.round(c) });
1199
+ });
1200
+ }
1201
+ return out;
1202
+ }
1203
+ /**
1204
+ * What can be said about the counts WITHOUT the studio's answer: above the
1205
+ * current cap is capped on every studio; between the two caps depends on the
1206
+ * studio. Used when the stored spec cannot be read back.
1207
+ */
1208
+ export function emitterCountWarnings(spec) {
1209
+ return emitterCountsToCheck(spec).map(({ where, asked }) => asked > EMITTER_MAX_PER_BURST
1210
+ ? `${where} ${asked} is over the ${EMITTER_MAX_PER_BURST}-particle cap per burst of the current studio (${EMITTER_LEGACY_MAX_PER_BURST} on older studios), so it draws at most ${EMITTER_MAX_PER_BURST}. Split it into bursts at the same t for more.`
1211
+ : `${where} ${asked} is over the ${EMITTER_LEGACY_MAX_PER_BURST}-particle cap per burst of studios before the ${EMITTER_MAX_PER_BURST} cap, and this studio did not report what it drew: on an older studio it draws ${EMITTER_LEGACY_MAX_PER_BURST}. Split it into bursts at the same t to be sure.`);
1212
+ }
1213
+ /**
1214
+ * An emitter spec as the studio reads it, and what the reply says about it.
1215
+ *
1216
+ * The studio reads velocities and offsets as [x, y] pairs only and drops any
1217
+ * other entry, so velocities: [{x, y}] made particles with no velocity and no
1218
+ * word (VELOC23). Point objects are turned into pairs here. And a burst draws
1219
+ * `count` particles, the i-th taking velocities[i % length]: with fewer
1220
+ * velocities than particles the extras repeat a velocity and draw on top of an
1221
+ * earlier particle, and with more the extras go unused, both in silence
1222
+ * (VELOCMISMATCH23). That is said, not changed: cycling is the studio's rule.
1223
+ * `assumeDefaultCount` is false for a set, whose spec is only the keys changed.
1224
+ */
1225
+ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1226
+ const warnings = [];
1227
+ if (!spec || typeof spec !== 'object')
1228
+ return { spec, warnings };
1229
+ const pairs = (v) => (Array.isArray(v)
1230
+ ? v.map((p) => (p && typeof p === 'object' && !Array.isArray(p) && ('x' in p || 'y' in p)
1231
+ ? [Number(p.x ?? 0), Number(p.y ?? 0)]
1232
+ : p))
1233
+ : v);
1234
+ const fix = (o) => {
1235
+ const out = { ...o };
1236
+ for (const k of ['velocities', 'offsets'])
1237
+ if (out[k] !== undefined)
1238
+ out[k] = pairs(out[k]);
1239
+ return out;
1240
+ };
1241
+ const out = fix(spec);
1242
+ if (Array.isArray(spec.bursts))
1243
+ out.bursts = spec.bursts.map((b) => (b && typeof b === 'object' ? fix(b) : b));
1244
+ // The studio's default count per burst (ParticleEmitter DEFAULT_LOOK).
1245
+ const DEFAULT_COUNT = 12;
1246
+ const mismatch = (where, count, velocities) => {
1247
+ if (!Array.isArray(velocities) || !velocities.length)
1248
+ return;
1249
+ const n = typeof count === 'number' && Number.isFinite(count) ? Math.round(count) : (assumeDefaultCount ? DEFAULT_COUNT : null);
1250
+ if (n === null || n === velocities.length)
1251
+ return;
1252
+ warnings.push(n > velocities.length
1253
+ ? `${where}: count ${n} with ${velocities.length} velocities: particle i takes velocities[i % ${velocities.length}], so ${n - velocities.length} particles repeat an earlier particle's path and draw on top of it. Give one velocity per particle, or set count to ${velocities.length}.`
1254
+ : `${where}: count ${n} with ${velocities.length} velocities: only the first ${n} velocities are used. Set count to ${velocities.length} to draw them all.`);
1255
+ };
1256
+ // The spec's own pair once; a burst only where it sets either itself (it inherits the rest).
1257
+ const bursts = Array.isArray(out.bursts) ? out.bursts : [];
1258
+ const inherited = bursts.some((b) => !b || typeof b !== 'object' || (b.count === undefined && b.velocities === undefined));
1259
+ if (out.velocities !== undefined && (out.count !== undefined || inherited))
1260
+ mismatch('spec', out.count, out.velocities);
1261
+ bursts.forEach((b, i) => {
1262
+ const own = (b && typeof b === 'object' ? b : {});
1263
+ if (own.count !== undefined || own.velocities !== undefined)
1264
+ mismatch(`bursts[${i}]`, own.count ?? out.count, own.velocities ?? out.velocities);
1265
+ });
1266
+ return { spec: out, warnings };
1267
+ }
1065
1268
  /**
1066
1269
  * moves_along_path, from the parameters a caller actually writes.
1067
1270
  *
@@ -1132,7 +1335,27 @@ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1132
1335
  if (len > 0) params.speed = len / (150 * ${duration});
1133
1336
  if (params.closed === undefined) params.closed = false;
1134
1337
  }` : ''}
1135
- ${hasDelay ? `if (!params.window) params.window = { start: ${delay} };` : ''}
1338
+ // AN EQUATION ROUTE WITH A DURATION (queue item 22, ORBITDUR). There are no
1339
+ // points here to turn a duration into speed, so it was dropped and the curve
1340
+ // ran at the default speed. A studio whose rule times an equation (FxTool
1341
+ // claude/engine-core-queue: its equation param says duration times it) gets
1342
+ // duration, and delay on the same playhead clock instead of a window (a
1343
+ // window would start the timed ride part-way through). An older studio hands
1344
+ // a timed equation to the arm that cannot read one and loses the curve, so it
1345
+ // keeps today's untimed ride and is told.
1346
+ const __warnings = [];
1347
+ let __eqTimed = false;
1348
+ ${hasDuration ? `if (params.equation && !hasPoints) {
1349
+ const eqDesc = rule && rule.params && rule.params.equation && rule.params.equation.description;
1350
+ if (typeof eqDesc === 'string' && /duration/.test(eqDesc)) {
1351
+ __eqTimed = true;
1352
+ params.duration = ${duration};
1353
+ ${hasDelay ? `params.delay = ${delay};` : ''}
1354
+ } else {
1355
+ __warnings.push('duration ${duration} was not applied: this studio cannot time an equation route (a timed equation loses its curve there), so the route stays untimed at speed ' + (params.speed !== undefined ? params.speed : 1) + ' (1 = 150 px/s). Update the studio, or pass params.path as points.');
1356
+ }
1357
+ }` : ''}
1358
+ ${hasDelay ? `if (!__eqTimed && !params.window) params.window = { start: ${delay} };` : ''}
1136
1359
  // Before its window opens the relation does not run, so the item stays where
1137
1360
  // it was left — the retest saw it parked at the path END. Put it at the start.
1138
1361
  if (Array.isArray(params.path) && params.path.length) {
@@ -1145,7 +1368,8 @@ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1145
1368
  app.historyManager.saveState();
1146
1369
  return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'sampled-points',
1147
1370
  points: Array.isArray(params.path) ? params.path.length : 0, speed: params.speed, closed: params.closed,
1148
- ${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window };
1371
+ ${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window,
1372
+ ...(__eqTimed ? { duration: params.duration } : {}), ...(__warnings.length ? { warnings: __warnings } : {}) };
1149
1373
  })();`.trim();
1150
1374
  }
1151
1375
  /**
@@ -1327,7 +1551,35 @@ if (!_target) {
1327
1551
  return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
1328
1552
  }`.trim();
1329
1553
  }
1330
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false) {
1554
+ /**
1555
+ * Text keys a studio may or may not animate (queue item 2, FxTool
1556
+ * claude/engine-core-queue): fontStyle, textAlign, lineHeight and letterSpacing
1557
+ * apply as keyframes on the current studio, which lists them (textAlign as
1558
+ * justification, lineHeight as leading) in listAnimatableProperties. An older
1559
+ * studio stores the keys and draws nothing, so the check runs IN THE PAGE
1560
+ * against that list, and only the keys it does not name are reported.
1561
+ */
1562
+ const TEXT_KEY_ENGINE_NAMES = {
1563
+ fontStyle: ['fontStyle'],
1564
+ textAlign: ['textAlign', 'justification'],
1565
+ lineHeight: ['lineHeight', 'leading'],
1566
+ letterSpacing: ['letterSpacing'],
1567
+ };
1568
+ /** A JS expression (run in the page) → the keys of `keys` this studio does not animate. '' when none are text keys. */
1569
+ function unanimatedTextKeysJs(keys) {
1570
+ const spec = [...new Set(keys)].filter((k) => k in TEXT_KEY_ENGINE_NAMES).map((k) => ({ key: k, names: TEXT_KEY_ENGINE_NAMES[k] }));
1571
+ if (!spec.length)
1572
+ return '';
1573
+ return `(function() {
1574
+ let __l = null;
1575
+ try { __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null; } catch (_) { __l = null; }
1576
+ const __names = ((__l && __l.properties) || []).map(function(p) { return p && p.name; });
1577
+ return ${JSON.stringify(spec)}.filter(function(s) { return !s.names.some(function(n) { return __names.indexOf(n) >= 0; }); }).map(function(s) { return s.key; });
1578
+ })()`;
1579
+ }
1580
+ /** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
1581
+ const UNANIMATED_TEXT_KEYS_SENTENCE = `' is not an animatable property in this studio (app.listAnimatableProperties does not list it), so it had no effect. The current studio animates fontStyle, textAlign, lineHeight and letterSpacing; here, set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.'`;
1582
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false, easingAt) {
1331
1583
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1332
1584
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1333
1585
  const opts = { duration: calculatedDuration, loop };
@@ -1343,6 +1595,11 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1343
1595
  // 2475333b). An engine without merge ignores the option and replaces.
1344
1596
  if (append)
1345
1597
  opts.mode = 'merge';
1598
+ // EASEPLACE (queue item 5): which side of a key its easing shapes. The
1599
+ // current studio reads (and removes) the option; an older one leaves it on
1600
+ // the options object, which is how the result can say it was not applied.
1601
+ if (easingAt !== undefined)
1602
+ opts.easingAt = easingAt;
1346
1603
  // A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
1347
1604
  // the audio item's sentinel, and the mix reads one static audioGain per clip,
1348
1605
  // so {audioGain: 0→1} answered success, duration 15, and exported at a flat
@@ -1394,12 +1651,16 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1394
1651
  const loopNote = loop
1395
1652
  ? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
1396
1653
  : '';
1397
- const easingNote = first?.easing && first.easing !== 'linear'
1398
- ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
1654
+ const easingNote = easingAt !== 'departure' && first?.easing && first.easing !== 'linear'
1655
+ ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect by default: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to, or pass easingAt: 'departure' to read every easing as the move LEAVING its key (current studio).`)}`
1399
1656
  : '';
1400
- const unsupportedKeys = [...new Set(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))]
1401
- .filter((k) => k === 'letterSpacing');
1402
- const letterSpacingWarning = unsupportedKeys.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(unsupportedKeys)}, warning: ${JSON.stringify(`${unsupportedKeys.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
1657
+ const textKeysJs = unanimatedTextKeysJs(keyframes.flatMap((k) => Object.keys(k.properties ?? {})));
1658
+ const textKeyCheck = textKeysJs ? `
1659
+ const __ignored = ${textKeysJs};
1660
+ if (__ignored.length) __warn.push(__ignored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE});` : '';
1661
+ const easingAtCheck = easingAt !== undefined ? `
1662
+ if (__opts && Object.prototype.hasOwnProperty.call(__opts, 'easingAt')) __warn.push('easingAt ' + ${JSON.stringify(JSON.stringify(easingAt))} + ' was not applied: this studio predates it, so every easing was read as the move ARRIVING at its key. Put each easing on the key the move goes to instead.');` : '';
1663
+ const runtimeWarning = `${textKeysJs ? ', ...(__ignored.length ? { ignoredProperties: __ignored } : {})' : ''}, ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
1403
1664
  const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
1404
1665
  return `
1405
1666
  // Apply keyframe animation to ${itemId}
@@ -1410,7 +1671,9 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1410
1671
  // merge support — an older engine takes the option and replaces anyway.
1411
1672
  const __ki = _target && (_target.item || _target);
1412
1673
  const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1413
- app.addAnimation(${addAnimTarget}, ${keyframesJson}, ${JSON.stringify(opts)});
1674
+ const __opts = ${JSON.stringify(opts)};
1675
+ app.addAnimation(${addAnimTarget}, ${keyframesJson}, __opts);
1676
+ const __warn = [];${easingAtCheck}${textKeyCheck}
1414
1677
  const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1415
1678
  const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
1416
1679
  // Property gaps across the merged track (the same check a single call makes
@@ -1423,7 +1686,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1423
1686
  ? { appended: true, previousKeys: __prev.length, keys: __now.length }
1424
1687
  : { appended: false, replaced: true, previousKeys: __prev.length, warning: 'append was asked for, but this studio replaced the existing track (' + __prev.length + ' key(s)) instead of adding to it: it has no merge mode. Put every key in one keyframe_animate call.' })`
1425
1688
  : `{ replaced: true, previousKeys: __prev.length, note: 'the item already had a keyframe track (' + __prev.length + ' key(s)); this call replaced it. To add keys to it instead, pass append: true.' }`};
1426
- return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${letterSpacingWarning} };
1689
+ return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${runtimeWarning} };
1427
1690
  })();
1428
1691
  `.trim();
1429
1692
  }
@@ -1855,9 +2118,11 @@ const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
1855
2118
  */
1856
2119
  function generateExportSVGCode() {
1857
2120
  return `
1858
- // Export animated SVG
2121
+ // Export animated SVG. An export that produced nothing is a failure, named, not a success with an empty string.
1859
2122
  const svgString = app.exportAnimatedSVG();
1860
- ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
2123
+ (typeof svgString !== 'string' || svgString.length === 0)
2124
+ ? ({ success: false, step: 'export', error: 'export failed at the export step: exportAnimatedSVG returned ' + (svgString === '' ? 'an empty string' : (svgString === null ? 'null' : typeof svgString)) + ', so there is no SVG to return' })
2125
+ : ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
1861
2126
  `.trim();
1862
2127
  }
1863
2128
  /**
@@ -2185,6 +2450,15 @@ ${itemSettersApplyJs(s, '__sid', '__res')}
2185
2450
  return __res;
2186
2451
  })();`;
2187
2452
  }
2453
+ /**
2454
+ * A batch op's item reference as a JS expression: `$N` is the Nth item this
2455
+ * batch created, anything else is an id, quoted. Only digits follow the `$` —
2456
+ * the raw splice it replaces put `$1);…;(` straight into the snippet, and an id
2457
+ * with a quote in it broke the batch's syntax.
2458
+ */
2459
+ export function batchRef(raw) {
2460
+ return typeof raw === 'string' && /^\$\d+$/.test(raw) ? `__ref(${raw.slice(1)})` : JSON.stringify(String(raw));
2461
+ }
2188
2462
  function asReturningBody(snippet) {
2189
2463
  const single = snippet.trim();
2190
2464
  if (single.startsWith('(async function()'))
@@ -2730,7 +3004,9 @@ export class PinePaperCodeGenerator {
2730
3004
  // silence; refuse before anything is written. setStepTiming ships in the
2731
3005
  // same change, so it is the probe.
2732
3006
  const holds = validated.keyframes.some((k) => k.interpolation === 'hold' || k.hold === true);
2733
- const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
3007
+ const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits,
3008
+ // mode: 'merge' is the studio's name for append (B147).
3009
+ validated.append === true || validated.mode === 'merge', validated.easingAt);
2734
3010
  if (!holds)
2735
3011
  return code;
2736
3012
  return `(async function() {
@@ -3370,8 +3646,15 @@ if (typeof _gen === 'function') {
3370
3646
  const targetId = nameToId['${target}'];
3371
3647
  if (sourceId && targetId) {
3372
3648
  const params = ${paramsStr};
3373
- app.addRelation(sourceId, targetId, '${type}', params);
3374
- results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3649
+ // A refused relation is not listed as made (owner rule 1).
3650
+ if (app.addRelation(sourceId, targetId, '${type}', params)) {
3651
+ results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3652
+ } else {
3653
+ const __why = ${relationRefusalReasonJs(JSON.stringify(type))};
3654
+ (results.relationsRefused = results.relationsRefused || []).push({ source: '${source}', target: '${target}', type: '${type}', reason: __why || 'the studio refused it' });
3655
+ results.success = false;
3656
+ results.error = 'the studio refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';
3657
+ }
3375
3658
  }
3376
3659
  })();
3377
3660
  `);
@@ -4067,11 +4350,16 @@ throw new Error('Unknown diagram mode action: ${action}');
4067
4350
  */
4068
4351
  generateAgentBatchExecute(input) {
4069
4352
  const validated = AgentBatchExecuteInputSchema.parse(input);
4070
- const { operations, atomic } = validated;
4353
+ const { atomic } = validated;
4354
+ // ring / grid / orbit / stagger / patch become the batch's own ops (batch-macros.ts).
4355
+ const operations = expandBatchMacros(validated.operations);
4071
4356
  const isAtomic = atomic !== false;
4072
4357
  // Fold duplicate keyframe_animate targets so a later op can't silently
4073
4358
  // clobber an earlier track (app.addAnimation replaces, never merges).
4074
4359
  const mergePlan = planKeyframeMerges(operations);
4360
+ // Timeline ops compile here, on the server: the handler refuses a batch whose timeline does not compile before
4361
+ // anything runs; a direct caller gets the op's own failure instead.
4362
+ const timelines = compileBatchTimelines(operations);
4075
4363
  let code = `
4076
4364
  // Batch execute ${operations.length} operations
4077
4365
  (async function() {
@@ -4100,11 +4388,32 @@ throw new Error('Unknown diagram mode action: ${action}');
4100
4388
  };
4101
4389
  const results = [];
4102
4390
  let success = true;
4391
+ // A failure's words, never "undefined" for a thrown non-Error.
4392
+ const _why = (e, fb) => (e && typeof e.message === 'string' && e.message.trim()) ? e.message
4393
+ : (typeof e === 'string' && e.trim()) ? e : fb;
4394
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
4395
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
4396
+ // result. A studio without lintScene says nothing.
4397
+ const __ppLintNow = function () {
4398
+ try {
4399
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
4400
+ const r = app.lintScene();
4401
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
4402
+ } catch (e) {
4403
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
4404
+ }
4405
+ };
4406
+ // Every failed op as { step, itemId, reason }, beside the per-op results.
4407
+ const _failures = () => results.filter((r) => !r.success).map((r) => ({ step: r.step, itemId: r.itemId, reason: r.reason }));
4103
4408
 
4104
4409
  try {
4105
4410
  `;
4106
4411
  operations.forEach((op, index) => {
4107
- const opCode = this.generateBatchOperationCode(op, index, mergePlan);
4412
+ const opCode = op.type === 'timeline'
4413
+ ? (timelines.plans.has(index)
4414
+ ? emitTimelineOpBody(timelines.plans.get(index), (t) => (t.startsWith('$') ? `__ref(${t.substring(1)})` : JSON.stringify(t)))
4415
+ : `return { success: false, error: ${JSON.stringify(timelines.errors.filter((e) => e.path.startsWith(`operations[${index}]`)).map((e) => `${e.path}: ${e.message}`).join('; ') || 'the timeline did not compile')} };`)
4416
+ : this.generateBatchOperationCode(op, index, mergePlan);
4108
4417
  // $N variable references are documented as "items CREATED in earlier
4109
4418
  // operations" — only `create` results may extend the itemIds array.
4110
4419
  // animate/keyframe_animate/modify/delete also return { itemId } (the
@@ -4115,6 +4424,10 @@ throw new Error('Unknown diagram mode action: ${action}');
4115
4424
  // vehicles, 2026-07-30). `group` already follows this rule by
4116
4425
  // returning groupId instead of itemId.
4117
4426
  const claimsItemSlot = op.type === 'create';
4427
+ // The item this op acts on, for a failure's itemId: its own id field, else what its result names.
4428
+ const o = op;
4429
+ const opItem = [o.itemId, o.targetId, o.sourceId, o.id].find((v) => typeof v === 'string') ?? null;
4430
+ const opStep = typeof o._step === 'string' ? `op ${index}: ${o._step}` : `op ${index} (${op.type})`;
4118
4431
  code += `
4119
4432
  // Operation ${index}: ${op.type}
4120
4433
  try {
@@ -4131,7 +4444,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4131
4444
  if (failed${index}) {
4132
4445
  // ATOMIC: throw WITHOUT recording, and let the catch below record it
4133
4446
  // once. Doing both put two entries in for one failed op.
4134
- ${isAtomic ? `throw new Error(failed${index});` : `results.push({ index: ${index}, success: false, error: failed${index}, result: result${index} });
4447
+ ${isAtomic ? `const __e${index} = new Error(failed${index}); __e${index}.itemId = (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}; throw __e${index};` : `results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}, reason: failed${index}, error: failed${index}, result: result${index} });
4135
4448
  success = false;`}
4136
4449
  } else {
4137
4450
  results.push({ index: ${index}, success: true, result: result${index} });
@@ -4140,7 +4453,8 @@ throw new Error('Unknown diagram mode action: ${action}');
4140
4453
  }` : ''}
4141
4454
  }
4142
4455
  } catch (opError) {
4143
- results.push({ index: ${index}, success: false, error: opError.message });
4456
+ const __r${index} = _why(opError, 'operation ${index} (${op.type}) threw with no message');
4457
+ results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (opError && typeof opError.itemId === 'string') ? opError.itemId : ${JSON.stringify(opItem)}, reason: __r${index}, error: __r${index} });
4144
4458
  ${isAtomic ? 'throw opError;' : 'success = false;'}
4145
4459
  }
4146
4460
  `;
@@ -4160,15 +4474,19 @@ throw new Error('Unknown diagram mode action: ${action}');
4160
4474
  }
4161
4475
  return { success, itemIds, results, operationCount: ${operations.length},
4162
4476
  failedCount: _failed.length,
4163
- ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
4164
- canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4477
+ ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; '), failures: _failures() } : {}),
4478
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4165
4479
  } catch (e) {
4166
4480
  // A batch that created nothing keeps the previous batch's ids on record.
4167
4481
  if (typeof window !== 'undefined' && itemIds.length) {
4168
4482
  window.__pinepaper_batch_item_ids = itemIds;
4169
4483
  }
4170
4484
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4171
- return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4485
+ const __fails = _failures();
4486
+ const __reason = _why(e, 'the batch failed with no message');
4487
+ return { success: false, error: __reason, itemIds, results, operationCount: ${operations.length},
4488
+ failures: __fails.length ? __fails : [{ step: 'batch', itemId: null, reason: __reason }],
4489
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4172
4490
  }
4173
4491
  })();
4174
4492
  `;
@@ -4205,7 +4523,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4205
4523
  'backgroundColor', 'generatorName', 'generatorParams', 'generatorRegion',
4206
4524
  'width', 'height', 'preset', 'keyframes', 'duration', 'loop',
4207
4525
  'maskPreset', 'maskType', 'maskOptions', 'effectType', 'effectParams',
4208
- 'action', 'time', 'append'
4526
+ 'action', 'time', 'append', '_step'
4209
4527
  ]);
4210
4528
  for (const [k, v] of Object.entries(anyOp)) {
4211
4529
  if (!batchKeys.has(k) && v !== undefined && opProps[k] === undefined) {
@@ -4250,9 +4568,7 @@ return __created;
4250
4568
  return createCode;
4251
4569
  }
4252
4570
  case 'modify': {
4253
- const itemRef = op.itemId?.startsWith('$')
4254
- ? `__ref(${op.itemId.substring(1)})`
4255
- : `'${op.itemId}'`;
4571
+ const itemRef = batchRef(op.itemId);
4256
4572
  // modify_item's own emitter, as create is create_item's (see above):
4257
4573
  // the batch's modifyItem call missed text styles, font loading, audio
4258
4574
  // level, no-fill, lifetimes, smoothing and the unread-property report.
@@ -4265,15 +4581,21 @@ return __created;
4265
4581
  .replace(/;\s*$/, '');
4266
4582
  return `
4267
4583
  const __mid = ${itemRef};
4584
+ if (app.itemRegistry && app.itemRegistry.getItem && !app.itemRegistry.getItem(__mid) && typeof app.getItemsByName === 'function') {
4585
+ // A NAME HELD BY SEVERAL ITEMS is refused, not resolved: getItemById answers
4586
+ // the first holder, so a patch by name would change whichever came first.
4587
+ const __held = app.getItemsByName(__mid) || [];
4588
+ if (__held.length > 1) {
4589
+ return { success: false, itemId: __mid, error: __held.length + ' items are named ' + JSON.stringify(__mid) + ' (' + __held.map(function (h) { return (h && h.data && h.data.id) || '?'; }).join(', ') + '); pass one of their ids.' };
4590
+ }
4591
+ }
4268
4592
  const __r = await ${single};
4269
4593
  if (__r && __r.success !== false) __r.modified = true;
4270
4594
  return __r;
4271
4595
  `;
4272
4596
  }
4273
4597
  case 'animate':
4274
- const animItemRef = op.itemId?.startsWith('$')
4275
- ? `__ref(${op.itemId.substring(1)})`
4276
- : `'${op.itemId}'`;
4598
+ const animItemRef = batchRef(op.itemId);
4277
4599
  const animOpts = JSON.stringify(op.animationOptions || {});
4278
4600
  return `
4279
4601
  const targetId = ${animItemRef};
@@ -4283,12 +4605,8 @@ app.animate(item, { animationType: '${op.animationType}', ...${animOpts} });
4283
4605
  return { itemId: targetId, animationType: '${op.animationType}' };
4284
4606
  `;
4285
4607
  case 'relation':
4286
- const srcRef = op.sourceId?.startsWith('$')
4287
- ? `__ref(${op.sourceId.substring(1)})`
4288
- : `'${op.sourceId}'`;
4289
- const tgtRef = op.targetId?.startsWith('$')
4290
- ? `__ref(${op.targetId.substring(1)})`
4291
- : `'${op.targetId}'`;
4608
+ const srcRef = batchRef(op.sourceId);
4609
+ const tgtRef = batchRef(op.targetId);
4292
4610
  const relOpts = JSON.stringify(op.relationOptions || {});
4293
4611
  return `
4294
4612
  const sourceId = ${srcRef};
@@ -4298,12 +4616,19 @@ const target = app.getItemById(targetId);
4298
4616
  if (!source) throw new Error('Source not found: ' + sourceId);
4299
4617
  if (!target) throw new Error('Target not found: ' + targetId);
4300
4618
  const relationId = app.addRelation(source, target, '${op.relationType}', ${relOpts});
4619
+ // NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false
4620
+ // when it refuses — spring_follow "not loaded yet" — and this op returned
4621
+ // { relationId: false } with nothing marking it failed, so the batch said
4622
+ // success over a relation that does not exist.
4623
+ if (!relationId) {
4624
+ const __why = ${relationRefusalReasonJs(JSON.stringify(op.relationType))};
4625
+ return { success: false, relationId: false, sourceId, targetId, relationType: ${JSON.stringify(op.relationType)},
4626
+ error: 'the studio refused the ' + ${JSON.stringify(op.relationType)} + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };
4627
+ }
4301
4628
  return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4302
4629
  `;
4303
4630
  case 'delete':
4304
- const delRef = op.itemId?.startsWith('$')
4305
- ? `__ref(${op.itemId.substring(1)})`
4306
- : `'${op.itemId}'`;
4631
+ const delRef = batchRef(op.itemId);
4307
4632
  return `
4308
4633
  const targetId = ${delRef};
4309
4634
  const item = app.getItemById(targetId);
@@ -4314,7 +4639,7 @@ return { itemId: targetId, deleted: true };
4314
4639
  `;
4315
4640
  case 'group': {
4316
4641
  // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
4317
- const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `__ref(${ref.substring(1)})` : `'${ref}'`);
4642
+ const refs = (op.itemIds || []).map((ref) => batchRef(ref));
4318
4643
  const idsArrayExpr = `[${refs.join(', ')}]`;
4319
4644
  const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
4320
4645
  // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
@@ -4326,8 +4651,8 @@ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4326
4651
  case 'set_background':
4327
4652
  const bgColor = op.backgroundColor || op.color || op.properties?.backgroundColor || op.properties?.color || '#000000';
4328
4653
  return `
4329
- app.setBackgroundColor('${bgColor}');
4330
- return { success: true, backgroundColor: '${bgColor}' };
4654
+ app.setBackgroundColor(${JSON.stringify(String(bgColor))});
4655
+ return { success: true, backgroundColor: ${JSON.stringify(String(bgColor))} };
4331
4656
  `;
4332
4657
  case 'execute_generator':
4333
4658
  const genName = op.generatorName || 'drawSunburst';
@@ -4358,14 +4683,12 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
4358
4683
  return `
4359
4684
  // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
4360
4685
  // is replaced wholesale, so emitting both here would silently drop one.
4361
- return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)})` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
4686
+ return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget} };
4362
4687
  `;
4363
4688
  }
4364
4689
  const mergedPayload = mergePlan?.merged.get(index);
4365
4690
  const rawItemId = String(op.itemId ?? op.id ?? op.properties?.itemId ?? op.properties?.id);
4366
- const kfItemRef = rawItemId?.startsWith('$')
4367
- ? `__ref(${rawItemId.substring(1)})`
4368
- : `'${rawItemId}'`;
4691
+ const kfItemRef = batchRef(rawItemId);
4369
4692
  const kfSource = mergedPayload
4370
4693
  ? mergedPayload.keyframes
4371
4694
  : (op.keyframes || (op.properties && op.properties.keyframes) || (op.params && op.params.keyframes) || []);
@@ -4375,7 +4698,10 @@ return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)}
4375
4698
  : (op.duration || op.properties?.duration || (kfSource.length ? Math.max(...kfSource.map((k) => Number(k.time) || 0)) : 5));
4376
4699
  const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? op.properties?.loop ?? false);
4377
4700
  // Same contract as pinepaper_keyframe_animate: replace unless append.
4378
- const kfAppend = op.append === true || op.properties?.append === true;
4701
+ // mode: 'merge' is the studio's spelling (its keyframe_track_replaced
4702
+ // warning names it), so it means append here too (B147).
4703
+ const kfAppend = op.append === true || op.properties?.append === true
4704
+ || op.mode === 'merge' || op.properties?.mode === 'merge';
4379
4705
  // Batch ops are passthrough, so these arrive unvalidated: numbers only, and timeUnits from its enum.
4380
4706
  const kfExtra = [];
4381
4707
  for (const key of ['timeOffset', 'clipInPoint', 'clipOutPoint']) {
@@ -4387,9 +4713,9 @@ return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)}
4387
4713
  if (kfUnits === 'seconds' || kfUnits === 'ms')
4388
4714
  kfExtra.push(`timeUnits: '${kfUnits}'`);
4389
4715
  const kfExtraStr = kfExtra.length > 0 ? `, ${kfExtra.join(', ')}` : '';
4390
- const kfUnsupported = [...new Set((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})))]
4391
- .filter((k) => k === 'letterSpacing');
4392
- const kfIgnored = kfUnsupported.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(kfUnsupported)}, warning: ${JSON.stringify(`${kfUnsupported.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
4716
+ const kfTextKeysJs = unanimatedTextKeysJs((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})));
4717
+ const kfTextCheck = kfTextKeysJs ? `\nconst _kfIgnored = ${kfTextKeysJs};` : '';
4718
+ const kfIgnored = kfTextKeysJs ? `, ...(_kfIgnored.length ? { ignoredProperties: _kfIgnored, warning: _kfIgnored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE} } : {})` : '';
4393
4719
  return `
4394
4720
  const targetId = ${kfItemRef};
4395
4721
  const _target = (app.getItemById && app.getItemById(targetId))
@@ -4399,14 +4725,12 @@ if (!_target) {
4399
4725
  }
4400
4726
  const _kfItem = _target.item || _target;
4401
4727
  const _prevKeys = (_kfItem.data && Array.isArray(_kfItem.data.keyframes)) ? _kfItem.data.keyframes.length : 0;
4402
- app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ", mode: 'merge'" : ''}${kfExtraStr} });
4728
+ app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ", mode: 'merge'" : ''}${kfExtraStr} });${kfTextCheck}
4403
4729
  return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ', append: true' : ''}${kfAppend ? '' : `, ...(_prevKeys ? { track: { replaced: true, previousKeys: _prevKeys, note: 'the item already had a keyframe track; this op replaced it. To add keys to it instead, pass append: true.' } } : {})`}${kfIgnored} };
4404
4730
  `;
4405
4731
  }
4406
4732
  case 'apply_mask': {
4407
- const maskItemRef = op.itemId?.startsWith('$')
4408
- ? `__ref(${op.itemId.substring(1)})`
4409
- : `'${op.itemId}'`;
4733
+ const maskItemRef = batchRef(op.itemId);
4410
4734
  const maskOpts = JSON.stringify(op.maskOptions || {});
4411
4735
  const preset = op.maskPreset || op.maskType || 'wipeLeft';
4412
4736
  return `
@@ -4423,9 +4747,7 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
4423
4747
  `;
4424
4748
  }
4425
4749
  case 'apply_effect': {
4426
- const effItemRef = op.itemId?.startsWith('$')
4427
- ? `__ref(${op.itemId.substring(1)})`
4428
- : `'${op.itemId}'`;
4750
+ const effItemRef = batchRef(op.itemId);
4429
4751
  const effParams = JSON.stringify(op.effectParams || {});
4430
4752
  return `
4431
4753
  const targetId = ${effItemRef};
@@ -4910,6 +5232,19 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4910
5232
  // cannot be kept accurate — it breaks on any edit above it — so this repo
4911
5233
  // does not use them. Camera animation still
4912
5234
  // drives motion during export — framing only fixes the output frame.
5235
+ // THE SCENE LINT, BEFORE THE EXPORT (FxTool lintScene().warnings): what the file will show wrong without
5236
+ // anything failing — a centre-anchored text that runs out of the frame exports as "IGNAL" for "SIGNAL". Read from the
5237
+ // scene as it is, never a refusal; a studio without the rule says nothing, a lint that throws says so.
5238
+ const __lint = (function () {
5239
+ if (typeof app.lintScene !== 'function') return null;
5240
+ try {
5241
+ const r = app.lintScene();
5242
+ return r && Array.isArray(r.warnings) ? r.warnings : null;
5243
+ } catch (e) {
5244
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run before the export: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
5245
+ }
5246
+ })();
5247
+
4913
5248
  let cameraDims = null;
4914
5249
  if (framing === 'camera') {
4915
5250
  if (!['gif', 'apng', 'mp4', 'webm'].includes(format)) {
@@ -5458,7 +5793,7 @@ ${stillTime !== undefined ? `
5458
5793
  if (typeof fr.embedded === 'number') __tl.fonts.embedded = fr.embedded; // cumulative for the document
5459
5794
  if (typeof fr.hiddenTextItems === 'number') __tl.fonts.hiddenTextItems = (__tl.fonts.hiddenTextItems || 0) + fr.hiddenTextItems;
5460
5795
  }
5461
- } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: e && e.message }; }
5796
+ } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: (e && typeof e.message === 'string' && e.message) || 'embedding the text fonts failed with no reason given' }; }
5462
5797
  }
5463
5798
  if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
5464
5799
  if (__layer) {
@@ -5466,7 +5801,7 @@ ${stillTime !== undefined ? `
5466
5801
  const tr = await app.exportEngine.addTextLayer(doc, { x: 0, y: 0, width: wMM, height: hMM });
5467
5802
  if (tr && tr.ok !== false) { __tl.written += tr.linesWritten || 0; __tl.skipped += tr.linesSkipped || 0; }
5468
5803
  else __tl.reasons.push(id + ': ' + ((tr && tr.reason) || 'refused'));
5469
- } catch (e) { __tl.reasons.push(id + ': ' + e.message); }
5804
+ } catch (e) { __tl.reasons.push(id + ': ' + ((e && typeof e.message === 'string' && e.message) || 'the text layer failed with no reason given')); }
5470
5805
  }
5471
5806
  pagesOut.push({ sceneId: id, widthMM: Math.round(wMM * 10) / 10, heightMM: Math.round(hMM * 10) / 10 });
5472
5807
  }
@@ -5529,7 +5864,7 @@ ${stillTime !== undefined ? `
5529
5864
  result = { success: false, error: 'Unknown format: ' + format };
5530
5865
  }
5531
5866
  } catch (e) {
5532
- result = { success: false, error: e.message, platform, format };
5867
+ result = { success: false, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('the ' + format + ' export threw with no message'), platform, format };
5533
5868
  }
5534
5869
 
5535
5870
  // One site, after every branch has set its result: a successful export says
@@ -5567,13 +5902,44 @@ ${stillTime !== undefined ? `
5567
5902
  result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });
5568
5903
  }
5569
5904
  } catch (e) {
5570
- result = { success: false, platform, format, error: (e && e.message) || String(e) };
5905
+ result = { success: false, platform, format, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('re-encoding the still as ' + format + ' failed with no message') };
5571
5906
  }
5572
5907
  }
5573
5908
 
5574
5909
  if (result && result.success) Object.assign(result, fidelity(format));
5910
+ if (result && result.success && __lint && __lint.length && !result.lintWarnings) {
5911
+ result.lintWarnings = __lint;
5912
+ result.fidelity = result.fidelity || { warnings: [] };
5913
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__lint.map(function (w) {
5914
+ return { code: String(w.code || 'lint'), message: String(w.message || w.code || 'scene lint warning'), ...(w.itemId ? { items: [w.itemId] } : {}) };
5915
+ }));
5916
+ if (result.fidelity.note) delete result.fidelity.note;
5917
+ }
5575
5918
  if (result && result.success && __fit) result.platformFit = __fit;
5576
- // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5919
+ ${usesCanvasSize ? '' : ` // A PRESET SIZE IS NOT THE CANVAS SIZE, AND THAT IS SAID (21 PLATFORMOVERRIDE).
5920
+ // A named platform renders the FILE at the preset's size; the canvas is not
5921
+ // resized, so the scene keeps the canvas's layout, scaled to fill the file.
5922
+ // A 640x360 canvas under youtube came back 1920x1080 with nothing said. The
5923
+ // size the route reports is compared with the canvas; the current studio's
5924
+ // video report warns about an upscale too, but as plain text this reply does
5925
+ // not pass on, so it is said here for every studio.
5926
+ // Not said for camera framing (the camera chooses that frame) or an
5927
+ // unbounded canvas (its stills crop to the artwork, said on their own), and
5928
+ // a scale the caller asked for is theirs, not the preset's.
5929
+ if (result && result.success && framing !== 'camera' && __cv && !__cv.unbounded && __cv.width > 0 && __cv.height > 0
5930
+ && result.dimensions && typeof result.dimensions === 'object' && result.dimensions.width > 0 && result.dimensions.height > 0) {
5931
+ const __fw = result.dimensions.width, __fh = result.dimensions.height;
5932
+ const __us = ${scale !== undefined ? scale : 1};
5933
+ if (Math.abs(__fw - __cv.width * __us) > 2 || Math.abs(__fh - __cv.height * __us) > 2) {
5934
+ const __k = Math.round((__fw / __cv.width) * 1000) / 1000;
5935
+ result.platformSize = { preset: __presetBox.width + 'x' + __presetBox.height, canvas: __cv.width + 'x' + __cv.height, file: __fw + 'x' + __fh, scale: __k, canvasResized: false };
5936
+ result.fidelity = result.fidelity || { warnings: [] };
5937
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'platform_size_differs_from_canvas',
5938
+ message: 'the ' + platform + ' preset made the file ' + __fw + 'x' + __fh + ' while the canvas is ' + __cv.width + 'x' + __cv.height + ': the scene is drawn ' + (__k > 1 ? 'larger' : 'smaller') + ' (x' + __k + ') with the canvas\\'s layout, and the canvas itself is unchanged. For a file at the canvas size, export with platform: \\'auto\\'; for a scene laid out at ' + __fw + 'x' + __fh + ', resize the canvas (pinepaper_set_canvas_size) and re-lay it.' }]);
5939
+ if (result.fidelity.note) delete result.fidelity.note;
5940
+ }
5941
+ }
5942
+ `} // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5577
5943
  // aspect nuance but the wrong deliverable: tiktok on a 16:9 canvas came out
5578
5944
  // 1920x1080 with only a note (round 10, 10.7). Said where a caller looks.
5579
5945
  if (result && result.success && __fit) {
@@ -7480,14 +7846,39 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7480
7846
  // An async IIFE is still a `(`-led expression statement, so the governor
7481
7847
  // captures its value, and executeCode's eval fallback already resolves a
7482
7848
  // returned promise. Both paths await it; neither needed changing.
7849
+ //
7850
+ // The code runs inside its own async arrow so its `return` is still its value; the value comes back under
7851
+ // __ppValue with the scene lint beside it, and a throw comes back as { step, itemId, reason }, never a bare
7852
+ // message (and never "undefined" for a thrown non-Error).
7483
7853
  return `
7484
7854
  // ${description}
7485
7855
  (async function() {
7856
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
7857
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
7858
+ // result. A studio without lintScene says nothing.
7859
+ const __ppLintNow = function () {
7860
+ try {
7861
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
7862
+ const r = app.lintScene();
7863
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
7864
+ } catch (e) {
7865
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
7866
+ }
7867
+ };
7868
+ let __ppValue;
7486
7869
  try {
7870
+ __ppValue = await (async () => {
7487
7871
  ${validated.code}
7872
+ })();
7488
7873
  } catch (error) {
7489
- return { success: false, error: error.message };
7490
- }
7874
+ const __ppReason = (error && typeof error.message === 'string' && error.message.trim()) ? error.message
7875
+ : (typeof error === 'string' && error.trim()) ? error : 'the custom code threw with no message';
7876
+ return { success: false, step: 'execute_custom_code', itemId: (error && typeof error.itemId === 'string') ? error.itemId : null,
7877
+ reason: __ppReason, error: __ppReason, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
7878
+ }
7879
+ // Only wrapped when the lint has something to say: otherwise the value is returned exactly as before.
7880
+ const __ppL = __ppLintNow();
7881
+ return __ppL.length ? { __ppValue: __ppValue, __ppLint: __ppL } : __ppValue;
7491
7882
  })();
7492
7883
  `.trim();
7493
7884
  }
@@ -7818,7 +8209,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7818
8209
  `.trim();
7819
8210
  }
7820
8211
  generateImportImage(input) {
7821
- const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize } = input;
8212
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset } = input;
8213
+ // IMPORTSHADOW: the shadow keys, applied through the engine's own modify
8214
+ // door once the image is placed (and masked) — the route modify_item takes.
8215
+ const shadow = {};
8216
+ if (shadowColor !== undefined)
8217
+ shadow.shadowColor = shadowColor;
8218
+ if (shadowBlur !== undefined)
8219
+ shadow.shadowBlur = shadowBlur;
8220
+ if (shadowOffset !== undefined)
8221
+ shadow.shadowOffset = shadowOffset;
8222
+ const hasShadow = Object.keys(shadow).length > 0;
7822
8223
  // Each fragment used to be written with a LEADING comma, as if it followed
7823
8224
  // an entry that no longer exists — so the first one opened the object with
7824
8225
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -7916,6 +8317,16 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7916
8317
  }
7917
8318
  if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
7918
8319
  placed = masked;
8320
+ ` : ''}${hasShadow ? ` // The shadow goes on what is DRAWN, after a mask has replaced the raster.
8321
+ let __shadow = null;
8322
+ {
8323
+ const __want = ${JSON.stringify(shadow)};
8324
+ let __ok = false;
8325
+ try { __ok = app.modifyItem(itemId, __want) !== false; } catch (_) { __ok = false; }
8326
+ __shadow = __ok
8327
+ ? { applied: true, shadowColor: placed.shadowColor ? String(placed.shadowColor.toCSS ? placed.shadowColor.toCSS(true) : placed.shadowColor) : null, shadowBlur: placed.shadowBlur }
8328
+ : { applied: false, warning: 'the image was imported but its shadow was not applied — set it with pinepaper_modify_item on ' + itemId + '.' };
8329
+ }
7919
8330
  ` : ''} const b = placed.bounds;
7920
8331
  if (!b || !(b.width > 0) || !(b.height > 0)) {
7921
8332
  return { success: false, itemId: itemId, error: 'the image was placed with no size (' + (b ? b.width + 'x' + b.height : 'no bounds') + ') — it may not have loaded. Nothing visible was drawn.' };
@@ -7924,7 +8335,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7924
8335
  success: true,
7925
8336
  itemId: itemId,
7926
8337
  message: 'Image imported and placed on canvas.',
7927
- bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''},
8338
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
7928
8339
  // Stored smaller than its source (the studio's cap): said, with the
7929
8340
  // way to keep every pixel. The studio reports native size where it can.
7930
8341
  ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)
@@ -8275,6 +8686,53 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
8275
8686
  };
8276
8687
  })();
8277
8688
  `.trim();
8689
+ }
8690
+ /**
8691
+ * pinepaper_pattern (AR-2 P2): one stored declaration moves many items (FxTool app.addPattern / modifyPattern /
8692
+ * removePattern / listPatterns / getPattern). No keyframes are written; the engine evaluates the node per frame.
8693
+ */
8694
+ generatePattern(input) {
8695
+ const v = PatternInputSchema.parse(input);
8696
+ const S = (x) => JSON.stringify(x);
8697
+ const need = (fn) => `if (typeof app.${fn} !== 'function') return { success: false, error: 'app.${fn} unavailable — this studio predates patterns (FxTool AR-2 P1); update it' };`;
8698
+ const idNeeded = v.action !== 'list' && !v.id ? `return { success: false, error: '${v.action} needs the pattern id' };` : '';
8699
+ let body;
8700
+ switch (v.action) {
8701
+ case 'create': {
8702
+ const spec = { id: v.id, kind: v.kind, members: v.members, params: v.params || {}, ...(v.window ? { window: v.window } : {}) };
8703
+ body = `${need('addPattern')}
8704
+ if (!${S(v.kind ?? null)}) return { success: false, error: 'create needs kind: phase_wave or stagger' };
8705
+ if (!${S(v.members ?? null)}) return { success: false, error: 'create needs members: { names: [...] }, { ids: [...] }, { tag } or an array' };
8706
+ const r = app.addPattern(${S(spec)});
8707
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8708
+ break;
8709
+ }
8710
+ case 'modify': {
8711
+ const ch = { ...(v.params ? { params: v.params } : {}), ...(v.members ? { members: v.members } : {}), ...(v.window !== undefined ? { window: v.window } : {}) };
8712
+ body = `${need('modifyPattern')}
8713
+ const r = app.modifyPattern(${S(v.id)}, ${S(ch)});
8714
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8715
+ break;
8716
+ }
8717
+ case 'remove':
8718
+ body = `${need('removePattern')}
8719
+ const had = app.removePattern(${S(v.id)});
8720
+ return had ? { success: true, removed: ${S(v.id)} } : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8721
+ break;
8722
+ case 'get':
8723
+ body = `${need('getPattern')}
8724
+ const p = app.getPattern(${S(v.id)});
8725
+ return p ? Object.assign({ success: true }, p) : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8726
+ break;
8727
+ default:
8728
+ body = `${need('listPatterns')}
8729
+ const list = app.listPatterns();
8730
+ return { success: true, count: list.length, patterns: list };`;
8731
+ }
8732
+ return `(function() {
8733
+ ${idNeeded}
8734
+ ${body}
8735
+ })();`;
8278
8736
  }
8279
8737
  generateStagger(input) {
8280
8738
  const validated = StaggerInputSchema.parse(input);
@@ -10115,6 +10573,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10115
10573
  (async function() {
10116
10574
  if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
10117
10575
  const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
10576
+ if (!result || typeof result.json !== 'string' || result.json.length === 0) {
10577
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidget returned no scene JSON' + (result && result.error ? ' (' + result.error + ')' : '') };
10578
+ }
10118
10579
  // THE EMBED BOX WAS ALWAYS 16:9, AND IT IS NOT SELF-CONTAINED (round 9 GG,
10119
10580
  // 8.32). The engine writes aspect-ratio: 16/9 and max-width: 800px whatever
10120
10581
  // the scene, so a 300x250 ad sat letterboxed in a wide box. The box is sized
@@ -10150,6 +10611,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10150
10611
  (async function() {
10151
10612
  if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
10152
10613
  const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
10614
+ if (!result || typeof result.html !== 'string' || result.html.length === 0) {
10615
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidgetHTML returned no page' + (result && result.error ? ' (' + result.error + ')' : '') };
10616
+ }
10153
10617
  ${input.lang || input.dir || input.alt ? `// lang / dir / alt on the engine's page (it writes <html lang="en"> and no
10154
10618
  // text alternative). Applied as string edits on the one <html> tag and the
10155
10619
  // first <canvas>; the result says which landed.
@@ -11043,7 +11507,9 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11043
11507
  app.create(op.type, { id: op.id, x: op.x, y: op.y, width: op.width, height: op.height });
11044
11508
  itemIds.push(op.id);
11045
11509
  } else if (op.op === 'addRelation') {
11046
- app.addRelation(op.from, op.to, op.relation, op.params || {});
11510
+ if (!app.addRelation(op.from, op.to, op.relation, op.params || {})) {
11511
+ errors.push({ op: op.op, id: op.from + '->' + op.to, error: 'the studio refused the ' + op.relation + ' relation' });
11512
+ }
11047
11513
  }
11048
11514
  } catch (e) {
11049
11515
  errors.push({ op: op.op, id: op.id || (op.from + '->' + op.to), error: e && e.message });
@@ -11070,7 +11536,10 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11070
11536
  return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
11071
11537
  }
11072
11538
  const result = app.lintScene(${optsJson});
11073
- return { success: true, density: result.density, suggestions: result.suggestions };
11539
+ // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the
11540
+ // frame edge). Passed through when the studio reports them; an older studio has none to pass.
11541
+ return { success: true, density: result.density, suggestions: result.suggestions,
11542
+ ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}) };
11074
11543
  })();`.trim();
11075
11544
  }
11076
11545
  /**
@@ -11592,9 +12061,41 @@ ${guard}
11592
12061
  const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
11593
12062
  ${authoringFrameJs('__fr')}
11594
12063
  const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
11595
- const __actor = { x: __it.position.x, y: __it.position.y,
11596
- w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
11597
- sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked };
12064
+ // THE ACTOR AT THE FIRST BEAT, not where the playhead left it (B154 /
12065
+ // HOOKCLOCK3). Live values of a keyframed item depend on the playhead, so a
12066
+ // second call anchored at +0 / +57 / +22 / +43 px from session to session
12067
+ // and multiplied scales the first track had applied. app.poseAt (FxTool,
12068
+ // claude/engine-core-queue) answers at a track time; older studios fall back
12069
+ // to the live read.
12070
+ const __at0 = ${S(Math.min(...input.beats.map((b) => Number(b && b.at) || 0)))};
12071
+ // A track that already keys the beat properties defines the pose before the
12072
+ // first beat; the B109 hold key at 0 would overwrite its key there. A track
12073
+ // of other properties (a colour) still needs the hold key.
12074
+ const __beatProps = ['x', 'y', 'scaleX', 'scaleY', 'scale', 'rotation', 'opacity', 'position'];
12075
+ const __hasTrack = !!(__it.data && Array.isArray(__it.data.keyframes) && __it.data.keyframes.some(function (k) {
12076
+ return Object.keys((k && k.properties) || {}).some(function (p) { return __beatProps.indexOf(p) >= 0; });
12077
+ }));
12078
+ let __pose = null;
12079
+ let __poseErr = null;
12080
+ if (typeof app.poseAt === 'function') {
12081
+ try { __pose = app.poseAt(__it, __at0); } catch (e) { __poseErr = (e && e.message) || String(e); }
12082
+ if (__pose && !(isFinite(__pose.x) && isFinite(__pose.y) && __pose.width > 0 && __pose.height > 0)) __pose = null;
12083
+ }
12084
+ const __anchorWarnings = [];
12085
+ if (!__pose && __hasTrack) {
12086
+ __anchorWarnings.push((__poseErr ? 'app.poseAt failed (' + __poseErr + ')' : 'this studio has no app.poseAt')
12087
+ + ', so the beats start from the actor\\'s LIVE transform, which on a keyframed actor is wherever the playhead left it: the first move can jump. Update the studio, or seek to ' + __at0 + ' s before choreographing.');
12088
+ }
12089
+ if (!__pose && __it.className === 'Raster' && ${S(input.beats.some((b) => b && b.verb === 'roll'))}) {
12090
+ __anchorWarnings.push((__poseErr ? 'app.poseAt failed' : 'this studio has no app.poseAt')
12091
+ + ', so the roll turns on the picture\\'s half-box, not its drawn radius: a picture with a transparent margin slides as it rolls.');
12092
+ }
12093
+ const __actor = __pose
12094
+ ? { x: __pose.x, y: __pose.y, w: __pose.width, h: __pose.height, sx: __pose.scaleX || 1, sy: __pose.scaleY || 1, rot: isFinite(__pose.rotation) ? __pose.rotation : (__it.rotation || 0) + __baked, hasTrack: __hasTrack,
12095
+ rollRadius: __pose.drawnHeight ? __pose.drawnHeight / 2 : undefined }
12096
+ : { x: __it.position.x, y: __it.position.y,
12097
+ w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
12098
+ sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked, hasTrack: __hasTrack };
11598
12099
  const __r = (${compileChoreography.toString()})(__actor, __frame, ${S(input.beats)});
11599
12100
  if (__r.error) { return { success: false, itemId: ${id}, error: __r.error }; }
11600
12101
  const __prev = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes : [];
@@ -11603,7 +12104,7 @@ ${guard}
11603
12104
  const __other = __prev.reduce(function (acc, k) { Object.keys((k && k.properties) || {}).forEach(function (p) { if (__ours.indexOf(p) < 0 && acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
11604
12105
  app.addAnimation(${id}, __r.keyframes, { duration: Math.max(__r.end, __prev.reduce(function (m, k) { return Math.max(m, (k && k.time) || 0); }, 0)), loop: false, mode: 'merge' });
11605
12106
  const __now = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes.map(function (k) { return k && k.time; }) : [];
11606
- const __warnings = (__r.warnings || []).slice();
12107
+ const __warnings = __anchorWarnings.concat(__r.warnings || []);
11607
12108
  if (__prevTimes.length && !__prevTimes.every(function (t) { return __now.indexOf(t) >= 0; })) {
11608
12109
  __warnings.push('this studio replaced the existing track of the actor (' + __prevTimes.length + ' key(s)) instead of merging: put the other keys in after choreographing.');
11609
12110
  }
@@ -11964,29 +12465,56 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
11964
12465
  }
11965
12466
  }
11966
12467
  /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
11967
- generateEmitter(input) {
12468
+ generateEmitter(raw) {
11968
12469
  const S = (v) => JSON.stringify(v);
12470
+ const normalized = normalizeEmitterSpec(raw.spec, raw.action !== 'set');
12471
+ const input = { ...raw, spec: normalized.spec };
12472
+ // A COUNT OVER THE CAP IS SAID (B144). The cap is 10000 a burst on the
12473
+ // current studio and was 2000 before it, clamped in silence (2300 drew
12474
+ // ~1940). Which applies is read from the spec the studio STORED — its
12475
+ // normalised count is what it draws — so the reply names the number
12476
+ // actually drawn on whichever studio ran it. Only when nothing can be read
12477
+ // back does it fall back to what is known without the studio.
12478
+ const checks = emitterCountsToCheck(input.spec);
12479
+ const fallback = emitterCountWarnings(input.spec);
12480
+ const capFn = checks.length ? `
12481
+ function __capWarnings(stored) {
12482
+ const checks = ${S(checks)};
12483
+ if (!stored || typeof stored !== 'object') return ${S(fallback)};
12484
+ const out = [];
12485
+ for (const c of checks) {
12486
+ const b = c.index === null ? null : (Array.isArray(stored.bursts) ? stored.bursts[c.index] : null);
12487
+ const drawn = b && typeof b.count === 'number' ? b.count : stored.count;
12488
+ if (typeof drawn !== 'number') return ${S(fallback)};
12489
+ if (drawn < c.asked) out.push(c.where + ' ' + c.asked + ' is over this studio\\'s per-burst cap, so it draws ' + drawn + '. Split it into bursts at the same t for more.');
12490
+ }
12491
+ return out;
12492
+ }` : '';
12493
+ const fixed = normalized.warnings;
12494
+ const capJs = (stored) => checks.length || fixed.length
12495
+ ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(${S(fixed)}.concat(${checks.length ? `__capWarnings(${stored})` : '[]'})))`
12496
+ : '';
11969
12497
  if (input.action === 'set') {
11970
12498
  return `
11971
12499
  // Emitter: change a spec
11972
12500
  (function() {
11973
- if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
12501
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }${capFn}
11974
12502
  const r = app.setEmitter(${S(input.itemId)}, ${S(input.spec)});
11975
12503
  if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the emitter change was refused' }; }
11976
- return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec };
12504
+ return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec${capJs('r.spec')} };
11977
12505
  })();`.trim();
11978
12506
  }
11979
12507
  return `
11980
12508
  // Emitter: create
11981
12509
  (function() {
11982
- if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
12510
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }${capFn}
11983
12511
  const it = app.create('emitter', ${S(input.spec)});
11984
12512
  // A bad spec comes back null with an emitter-invalid warning; the reason
11985
12513
  // reaches this reply through the governor's warnings.
11986
12514
  if (!it) { return { success: false, error: 'the emitter spec was refused' }; }
11987
12515
  const id = it.data && (it.data.registryId || it.data.id);
11988
12516
  if (!id) { return { success: false, error: 'the emitter was made but not registered — no usable id' }; }
11989
- return { success: true, action: 'create', itemId: id };
12517
+ return { success: true, action: 'create', itemId: id${capJs('it.data && it.data.emitter')} };
11990
12518
  })();`.trim();
11991
12519
  }
11992
12520
  /** pinepaper_render_hook (D29): drawing saved as source, re-installed on load. */
@@ -12982,7 +13510,7 @@ ${needWorld}
12982
13510
  * already decided by a pure function, so nothing is left for the page to
12983
13511
  * work out.
12984
13512
  */
12985
- generateDesignCompose(scene, ops, style, sizeWarning) {
13513
+ generateDesignCompose(scene, ops, style, sizeWarning, report = {}) {
12986
13514
  const S = (v) => JSON.stringify(v);
12987
13515
  return `
12988
13516
  // Design: compose a ${style} scene — ${ops.length} items, laid out server-side
@@ -13012,7 +13540,7 @@ ${needWorld}
13012
13540
  // success is worse than a poster that says which four elements are missing.
13013
13541
  return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed,
13014
13542
  ...(canvasResized ? { canvasResized: canvasResized, note: 'the canvas was resized to the composition' + (${S(sizeWarning ?? '')} ? '' : '; pass width and height to compose at your canvas size') } : {}),
13015
- ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}) };
13543
+ ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}), ...${S(report)} };
13016
13544
  })();`.trim();
13017
13545
  }
13018
13546
  /**
@@ -13253,10 +13781,15 @@ ${guard('exportPNGSequence')}
13253
13781
  return Object.assign({ success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size }, __pngSeqDefaults);
13254
13782
  }
13255
13783
  if (Array.isArray(out)) {
13256
- return { success: true, format: 'png-sequence', frames: out.length,
13784
+ // NOT A SAVED FILE, and said so in fields: file null, and why.
13785
+ return { success: true, format: 'png-sequence', frames: out.length, file: null,
13786
+ why: 'the frames stayed in the page: the studio returned them as a list of frames, not a file, so no file was written. Pass download: true to have the browser download them as a zip.',
13257
13787
  note: 'the frames stayed in the page — pass download: true to write them out.' };
13258
13788
  }
13259
- return { success: true, format: 'png-sequence', result: out };
13789
+ // download: true — the studio handed the zip to the BROWSER's download, so no bytes reached this server and no file
13790
+ // was written by it. Said in fields, so it cannot be read as a saved file.
13791
+ return { success: true, format: 'png-sequence', result: out, file: null,
13792
+ why: 'the studio handed the zip to the browser as a download (download: true): it went to the browser\\'s download folder, no bytes reached the MCP server, and the server wrote no file.' };
13260
13793
  })();`.trim();
13261
13794
  }
13262
13795
  }