@pinepaper.studio/mcp-server 1.6.31 → 1.6.33

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 (97) hide show
  1. package/README.md +132 -1
  2. package/SECURITY.md +1 -1
  3. package/dist/agent/types.d.ts +13 -13
  4. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  5. package/dist/browser/puppeteer-controller.js +42 -17
  6. package/dist/browser/puppeteer-controller.js.map +1 -1
  7. package/dist/build-info.json +2 -2
  8. package/dist/cli.js +2 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/server.d.ts +12 -0
  14. package/dist/mcp/server.d.ts.map +1 -1
  15. package/dist/mcp/server.js +32 -2
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/mcp/stdio.d.ts +4 -0
  18. package/dist/mcp/stdio.d.ts.map +1 -1
  19. package/dist/mcp/stdio.js +22 -0
  20. package/dist/mcp/stdio.js.map +1 -1
  21. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  22. package/dist/ontology/upstream-vocabulary.js +2 -2
  23. package/dist/ontology/vocabulary.d.ts.map +1 -1
  24. package/dist/ontology/vocabulary.js +18 -0
  25. package/dist/ontology/vocabulary.js.map +1 -1
  26. package/dist/tools/batch-op-names.d.ts +6 -0
  27. package/dist/tools/batch-op-names.d.ts.map +1 -1
  28. package/dist/tools/batch-op-names.js +40 -2
  29. package/dist/tools/batch-op-names.js.map +1 -1
  30. package/dist/tools/choreograph.d.ts +8 -0
  31. package/dist/tools/choreograph.d.ts.map +1 -1
  32. package/dist/tools/choreograph.js +9 -7
  33. package/dist/tools/choreograph.js.map +1 -1
  34. package/dist/tools/definitions.d.ts.map +1 -1
  35. package/dist/tools/definitions.js +139 -63
  36. package/dist/tools/definitions.js.map +1 -1
  37. package/dist/tools/effect-class.d.ts.map +1 -1
  38. package/dist/tools/effect-class.js +7 -0
  39. package/dist/tools/effect-class.js.map +1 -1
  40. package/dist/tools/engine-lists.d.ts +7 -5
  41. package/dist/tools/engine-lists.d.ts.map +1 -1
  42. package/dist/tools/engine-lists.js +13 -5
  43. package/dist/tools/engine-lists.js.map +1 -1
  44. package/dist/tools/engine-methods.d.ts.map +1 -1
  45. package/dist/tools/engine-methods.js +3 -0
  46. package/dist/tools/engine-methods.js.map +1 -1
  47. package/dist/tools/engine-surface.d.ts +1 -1
  48. package/dist/tools/engine-surface.d.ts.map +1 -1
  49. package/dist/tools/engine-surface.js +26 -4
  50. package/dist/tools/engine-surface.js.map +1 -1
  51. package/dist/tools/handlers/generate.d.ts +2 -2
  52. package/dist/tools/handlers.d.ts.map +1 -1
  53. package/dist/tools/handlers.js +92 -13
  54. package/dist/tools/handlers.js.map +1 -1
  55. package/dist/tools/lane.d.ts +1 -1
  56. package/dist/tools/lane.d.ts.map +1 -1
  57. package/dist/tools/lane.js +3 -3
  58. package/dist/tools/lane.js.map +1 -1
  59. package/dist/tools/minimal-descriptions.js +1 -1
  60. package/dist/tools/minimal-descriptions.js.map +1 -1
  61. package/dist/tools/reply-fields.d.ts +15 -0
  62. package/dist/tools/reply-fields.d.ts.map +1 -0
  63. package/dist/tools/reply-fields.js +117 -0
  64. package/dist/tools/reply-fields.js.map +1 -0
  65. package/dist/tools/scene-cache-export.d.ts.map +1 -1
  66. package/dist/tools/scene-cache-export.js +2 -1
  67. package/dist/tools/scene-cache-export.js.map +1 -1
  68. package/dist/tools/shape-params.d.ts +2 -2
  69. package/dist/tools/shape-params.d.ts.map +1 -1
  70. package/dist/tools/shape-params.js +10 -2
  71. package/dist/tools/shape-params.js.map +1 -1
  72. package/dist/tools/stick-vocabulary.d.ts +4 -4
  73. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  74. package/dist/tools/stick-vocabulary.js +15 -4
  75. package/dist/tools/stick-vocabulary.js.map +1 -1
  76. package/dist/types/code-generator.d.ts +8 -0
  77. package/dist/types/code-generator.d.ts.map +1 -1
  78. package/dist/types/code-generator.js +342 -67
  79. package/dist/types/code-generator.js.map +1 -1
  80. package/dist/types/schemas.d.ts +277 -111
  81. package/dist/types/schemas.d.ts.map +1 -1
  82. package/dist/types/schemas.js +79 -7
  83. package/dist/types/schemas.js.map +1 -1
  84. package/dist/utils/env.d.ts +13 -41
  85. package/dist/utils/env.d.ts.map +1 -1
  86. package/dist/utils/env.js +25 -43
  87. package/dist/utils/env.js.map +1 -1
  88. package/dist/utils/exit-when-client-gone.d.ts +10 -0
  89. package/dist/utils/exit-when-client-gone.d.ts.map +1 -0
  90. package/dist/utils/exit-when-client-gone.js +21 -0
  91. package/dist/utils/exit-when-client-gone.js.map +1 -0
  92. package/dist/vendor/design/design-style.d.ts +2 -2
  93. package/dist/vendor/design/design-style.d.ts.map +1 -1
  94. package/dist/vendor/design/design-style.js +5 -2
  95. package/dist/vendor/design/design-style.js.map +1 -1
  96. package/manifest.json +1 -1
  97. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  import { compileChoreography } from '../tools/choreograph.js';
8
8
  import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
9
9
  import { normalizeSvgSize } from '../utils/svg-size.js';
10
- import { WORLD3D_COLOR_PATHS, PATTERN_KINDS, EXPRESSION_FUNCTION_ARITY, EXPRESSION_CONSTANTS, EXPRESSION_REJECTED, RELATION_PARAM_KEYS, ANIMATION_OPTION_KEYS } from '../tools/engine-lists.js';
10
+ import { WORLD3D_COLOR_PATHS, PATTERN_KINDS, EXPRESSION_FUNCTION_ARITY, EXPRESSION_CONSTANTS, EXPRESSION_REJECTED, RELATION_PARAM_KEYS, RELATION_LOOP_PARAMS, ANIMATION_OPTION_KEYS, FILTER_TYPES, FILTER_PARAM_RANGES } from '../tools/engine-lists.js';
11
11
  import { expressionGrammarText } from '../tools/expression-grammar.js';
12
12
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
13
13
  import { NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
@@ -16,6 +16,16 @@ import { jsLiteral } from '../utils/js-literal.js';
16
16
  /** A value spliced into a generated `//` comment, on one line: a line break in caller text would end the comment and run the rest as code. */
17
17
  /** How many relations pinepaper_lint_scene lists back; past it the result says how many there are. */
18
18
  const RELATIONS_READBACK_CAP = 200;
19
+ /**
20
+ * A relation's parameters as a caller may read them: the engine's own rule for what leaves a relation
21
+ * (RelationRegistry.stripTransientParams, its save boundary), applied in the page because that is where the value is.
22
+ *
23
+ * The engine keeps its working state in the stored params: _itemRegistry (written on the first frame), _initialized,
24
+ * _startTime, _affectedItems, listeners as functions. Read back raw, a reply carried the registry, so it failed the page
25
+ * bridge's structured clone and took the walker's path, where the whole reply was truncated to fit. Sent back in an update
26
+ * op, the same keys overwrite live state: the engine fills those slots only when empty, so a stale copy stays for good.
27
+ */
28
+ const RELATION_PUBLIC_PARAMS_JS = "((p) => Object.fromEntries(Object.entries(p || {}).filter(([k, v]) => k.charAt(0) !== '_' && typeof v !== 'function')))";
19
29
  function oneLine(v) {
20
30
  return String(v).replace(/[\r\n\u2028\u2029]+/g, ' ');
21
31
  }
@@ -182,12 +192,13 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
182
192
  if (properties.content === undefined && properties.text !== undefined) {
183
193
  properties = { ...properties, content: properties.text };
184
194
  }
195
+ // The alias replaces the key: left beside justification, the engine's receipt
196
+ // reported textAlign 'justify' as refused while it had been applied.
185
197
  if (properties.justification === undefined) {
186
- if (properties.textAlign !== undefined) {
187
- properties = { ...properties, justification: properties.textAlign };
188
- }
189
- else if (properties.align !== undefined) {
190
- properties = { ...properties, justification: properties.align };
198
+ const alignKey = properties.textAlign !== undefined ? 'textAlign' : properties.align !== undefined ? 'align' : null;
199
+ if (alignKey) {
200
+ const { [alignKey]: alignValue, ...rest } = properties;
201
+ properties = { ...rest, justification: cssAlignToJustification(alignValue) };
191
202
  }
192
203
  }
193
204
  // Extract special properties that need separate handling
@@ -218,6 +229,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
218
229
  textDirection.resolved = baseDirection(createProps.content);
219
230
  }
220
231
  const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
232
+ const textTypography = itemType === 'text' ? typographyRequest(createProps) : [];
221
233
  const createFillOpacity = createProps.fillOpacity !== undefined;
222
234
  const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
223
235
  const params = {
@@ -422,6 +434,8 @@ const __scale = (function(it) {
422
434
  code += emitDirectionCheck('item', textDirection);
423
435
  if (textTabular)
424
436
  code += emitTabularCheck('item');
437
+ if (textTypography.length)
438
+ code += emitTypographyCheck('item', textTypography);
425
439
  if (createFillOpacity)
426
440
  code += emitFillOpacityCheck('item');
427
441
  if (textStrokePos)
@@ -476,7 +490,7 @@ String(outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : '') +
476
490
  String(textFit ? ', textFit: __textFit' : '') +
477
491
  String(textDirection ? ', direction: __direction' : '') +
478
492
  String(textTabular ? ', tabularFigures: __tabular' : '') +
479
- String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
493
+ String(textTypography.length ? ', typography: __typography' : '') + String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
480
494
  ? `, ignoredProperties: ${JSON.stringify(ignored)}` : '') + String(warnings.length > 0 ? `, warning: ${JSON.stringify(warnings.join(' '))}` : '') + " });");
481
495
  // Loading a font is async, and it has to happen BEFORE create() measures the
482
496
  // text — so only this case becomes an async IIFE; every other create stays a
@@ -743,6 +757,50 @@ function anchorHold(anchor) {
743
757
  const a = String(anchor ?? '').toLowerCase();
744
758
  return /^top/.test(a) ? 'top' : /^bottom/.test(a) ? 'bottom' : undefined;
745
759
  }
760
+ /**
761
+ * wordSpacing, textTransform, textDecoration and justify (FxTool #289, WORDSPC).
762
+ * The engine stores them on item.data and its text layer draws them; a studio
763
+ * before #289 has no such layer and the keys are lost. These keys are on the
764
+ * current engine's read list (shape-params.ts), so the unread-key report no
765
+ * longer names them; the reply instead reads item.data, so an older studio is
766
+ * still named rather than reported as done.
767
+ */
768
+ /**
769
+ * textAlign / align, the CSS names, as Paper's justification: start / end read
770
+ * left / right and centre reads center, as the engine's own textAlign door does.
771
+ * Anything else passes through for the engine to judge (and report).
772
+ */
773
+ function cssAlignToJustification(v) {
774
+ if (typeof v !== 'string')
775
+ return v;
776
+ const k = v.trim().toLowerCase();
777
+ return { start: 'left', end: 'right', centre: 'center', left: 'left', center: 'center', right: 'right', justify: 'justify' }[k] ?? v;
778
+ }
779
+ const TYPOGRAPHY_KEYS = ['wordSpacing', 'textTransform', 'textDecoration'];
780
+ function typographyRequest(props) {
781
+ const keys = TYPOGRAPHY_KEYS.filter((k) => props[k] !== undefined);
782
+ const align = props.justification ?? props.textAlign ?? props.align;
783
+ if (typeof align === 'string' && align.trim().toLowerCase() === 'justify')
784
+ keys.push('justify');
785
+ return keys;
786
+ }
787
+ function emitTypographyCheck(itemExpr, keys) {
788
+ return ("\nconst __typography = (function(it, keys) {\n if (!it || it.className !== 'PointText') return { applied: false, note: keys.join(', ') + ' apply to text items only.' };\n const d = it.data || {};\n if (typeof app._applyTextTypography !== 'function') return { applied: false, ignored: keys, note: 'this studio does not draw ' + keys.join(', ') + ' (it needs an engine with text typography): the text is drawn without ' + (keys.length > 1 ? 'them' : 'it') + '. Set the case in the content itself; for an underline, a line item under the text.' };\n const out = { applied: true };\n const unset = [];\n for (const k of keys) {\n const v = k === 'justify' ? (d.justify === true ? true : undefi" +
789
+ "ned) : d[k];\n out[k] = v === undefined ? null : v;\n if (v === undefined) unset.push(k);\n }\n if (unset.length) out.note = unset.join(', ') + (unset.length > 1 ? ' are' : ' is') + ' not set on the item now: a clearing value (none / normal / 0) removes it, and a value the studio cannot draw is refused by name in the engine receipt.';\n return out;\n})(" +
790
+ String(itemExpr) +
791
+ ", " +
792
+ String(JSON.stringify(keys)) +
793
+ ");"
794
+
795
+
796
+
797
+
798
+
799
+
800
+
801
+
802
+ );
803
+ }
746
804
  function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
747
805
  return `
748
806
  if (typeof app.fitText === 'function') {
@@ -978,12 +1036,12 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
978
1036
  const aliased = new Set();
979
1037
  if (restProperties.justification === undefined) {
980
1038
  if (restProperties.textAlign !== undefined) {
981
- restProperties.justification = restProperties.textAlign;
1039
+ restProperties.justification = cssAlignToJustification(restProperties.textAlign);
982
1040
  delete restProperties.textAlign;
983
1041
  aliased.add('textAlign');
984
1042
  }
985
1043
  else if (restProperties.align !== undefined) {
986
- restProperties.justification = restProperties.align;
1044
+ restProperties.justification = cssAlignToJustification(restProperties.align);
987
1045
  delete restProperties.align;
988
1046
  aliased.add('align');
989
1047
  }
@@ -1001,6 +1059,7 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
1001
1059
  delete restProperties.fit;
1002
1060
  const modDirection = takeDirection(restProperties);
1003
1061
  const modTabular = wantsTabular(restProperties) === true;
1062
+ const modTypography = typographyRequest(restProperties);
1004
1063
  const modFillOpacity = restProperties.fillOpacity !== undefined;
1005
1064
  const modStrokePos = takeStrokePosition(restProperties, true);
1006
1065
  // modifyItem(id, changes), NOT select() + modify().
@@ -1145,12 +1204,12 @@ const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyfr
1145
1204
  "\n" +
1146
1205
  String(modDirection ? emitDirectionCheck(`app.getItemById(${ID})`, modDirection) : '') +
1147
1206
  String(modTabular ? emitTabularCheck(`app.getItemById(${ID})`) : '') +
1207
+ String(modTypography.length ? emitTypographyCheck(`app.getItemById(${ID})`, modTypography) : '') +
1148
1208
  String(modFillOpacity ? emitFillOpacityCheck(`app.getItemById(${ID})`) : '') +
1149
1209
  String(modStrokePos ? emitStrokePositionCheck(`app.getItemById(${ID})`, modStrokePos) : '') +
1150
1210
  "\nreturn { success: true, itemId: " +
1151
1211
  String(ID) +
1152
- String(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.' } : {})` : '') +
1153
- String(modFit ? ', textFit: __textFit' : '') + String(modDirection ? ', direction: __direction' : '') + String(modTabular ? ', tabularFigures: __tabular' : '') + String(modFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(modStrokePos ? ', strokePosition: __strokePos' : '') + String(affine ? `, affine: ${jsLiteral({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : '') + String(modLifetime ? ', lifetime: __lifetime' : '') + String(modFont ? ', ...(__font ? { font: __font } : {})' : '') + String(modWarning) + " };");
1212
+ String(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.' } : {})` : '') + String(modFit ? ', textFit: __textFit' : '') + String(modDirection ? ', direction: __direction' : '') + String(modTabular ? ', tabularFigures: __tabular' : '') + String(modTypography.length ? ', typography: __typography' : '') + String(modFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(modStrokePos ? ', strokePosition: __strokePos' : '') + String(affine ? `, affine: ${jsLiteral({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : '') + String(modLifetime ? ', lifetime: __lifetime' : '') + String(modFont ? ', ...(__font ? { font: __font } : {})' : '') + String(modWarning) + " };");
1154
1213
  // Wrapped, because the body now RETURNS early on a miss rather than falling
1155
1214
  // through to an unconditional success. Async only when a font must load
1156
1215
  // before modifyItem re-measures the text.
@@ -1391,6 +1450,45 @@ function editDistance(a, b) {
1391
1450
  }
1392
1451
  return row[b.length];
1393
1452
  }
1453
+ /**
1454
+ * The params keys a custom relation reads, worked out when it is registered: what it declares, every `params.X` its
1455
+ * compute / apply source reads, and the index / count a name pattern adds. null when the source uses params any other
1456
+ * way (params[k], destructuring, handing params on), because then the list could not be trusted and a warning could
1457
+ * name a key that works; null means no check, never a wrong one (RELWARN27: a bogus option on a built-in relation was
1458
+ * named, the same option on a custom one was stored in silence).
1459
+ */
1460
+ export function customRelationReads(params, computeFunction, applyFunction) {
1461
+ const src = `${String(computeFunction ?? '')}\n${String(applyFunction ?? '')}`.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
1462
+ const bare = [...src.matchAll(/\bparams\b(?!\s*\??\.\s*[A-Za-z_$])/g)];
1463
+ if (bare.length)
1464
+ return null;
1465
+ const keys = new Set(['index', 'count']);
1466
+ if (params && typeof params === 'object')
1467
+ Object.keys(params).forEach((k) => keys.add(k));
1468
+ for (const m of src.matchAll(/\bparams\s*\??\.\s*([A-Za-z_$][\w$]*)/g))
1469
+ keys.add(m[1]);
1470
+ return [...keys].sort();
1471
+ }
1472
+ /**
1473
+ * A relation's options checked against a CUSTOM relation's read list, in the page (the rule was registered there, maybe
1474
+ * in an earlier call). A fragment for a result object: `, ...{ key: warning }` or nothing. Built-ins are checked here
1475
+ * already (relationOptionWarning), so only a type without a built-in list is asked about.
1476
+ */
1477
+ function customOptionWarningJs(relationType, params, key, injected = []) {
1478
+ if (typeof relationType !== 'string' || RELATION_PARAM_KEYS[relationType] || !params || typeof params !== 'object')
1479
+ return '';
1480
+ const keys = Object.keys(params);
1481
+ if (!keys.length)
1482
+ return '';
1483
+ return `, ...(function () { try {
1484
+ var cr = typeof window !== 'undefined' && window.__ppCustomRelations && window.__ppCustomRelations[${jsLiteral(relationType)}];
1485
+ if (!cr || !Array.isArray(cr.reads)) return {};
1486
+ var skip = ${JSON.stringify([...injected, ...RELATION_LOOP_PARAMS])};
1487
+ var un = ${JSON.stringify(keys)}.filter(function (k) { return cr.reads.indexOf(k) < 0 && skip.indexOf(k) < 0; });
1488
+ if (!un.length) return {};
1489
+ return { ignoredOptions: un, ${key}: ${jsLiteral(relationType)} + ' does not read ' + un.join(', ') + ', so ' + (un.length > 1 ? 'they were' : 'it was') + ' ignored. ' + ${jsLiteral(relationType)} + ' reads: ' + cr.reads.join(', ') + '.' };
1490
+ } catch (e) { return {}; } })()`;
1491
+ }
1394
1492
  export function relationOptionWarning(relationType, params, injected = []) {
1395
1493
  const flip = equationFlipNote(relationType, params);
1396
1494
  const unread = unreadOptionWarning(relationType, params, injected);
@@ -1410,10 +1508,25 @@ function equationFlipNote(relationType, params) {
1410
1508
  return null;
1411
1509
  return 'params.equation is read with y UP (flipY defaults to true), so on screen the curve is mirrored about cy. If you wrote it in screen coordinates (y down), add flipY: false to the equation.';
1412
1510
  }
1413
- function unreadOptionWarning(relationType, params, injected) {
1511
+ /** What a built-in relation reads: its rule's params, and what the relation loop reads on every relation. */
1512
+ function relationReads(relationType) {
1414
1513
  const keys = typeof relationType === 'string' ? RELATION_PARAM_KEYS[relationType] : undefined;
1514
+ return keys ? [...keys, ...RELATION_LOOP_PARAMS] : undefined;
1515
+ }
1516
+ function unreadOptionWarning(relationType, params, injected) {
1517
+ const keys = relationReads(relationType);
1415
1518
  return keys ? unreadKeysWarning(String(relationType), keys, params, injected) : null;
1416
1519
  }
1520
+ const unreadKeys = (keys, params, injected = []) => keys && params && typeof params === 'object' ? Object.keys(params).filter((k) => !keys.includes(k) && !injected.includes(k)) : [];
1521
+ /**
1522
+ * A relation reply's fields for its options: the unread keys as data (ignoredOptions, which the reply's
1523
+ * ignoredArguments is built from) beside the sentence. Built-ins are known here, a custom relation only in the page.
1524
+ */
1525
+ function relationOptionFields(relationType, params, key, injected = []) {
1526
+ const unread = unreadKeys(relationReads(relationType), params, injected);
1527
+ const warning = relationOptionWarning(relationType, params, injected);
1528
+ return `${unread.length ? `, ignoredOptions: ${jsLiteral(unread)}` : ''}${warning ? `, ${key}: ${jsLiteral(warning)}` : ''}${customOptionWarningJs(relationType, params, key, injected)}`;
1529
+ }
1417
1530
  /** The keys of `params` outside `keys`, named with a "did you mean" and the list that is read; null when all are read. */
1418
1531
  function unreadKeysWarning(reader, keys, params, injected = []) {
1419
1532
  if (!params || typeof params !== 'object')
@@ -1470,7 +1583,7 @@ String(jsLiteral(targetId)) +
1470
1583
  ", " +
1471
1584
  String(jsLiteral(relationType)) +
1472
1585
  ", " +
1473
- String(JSON.stringify(params, null, 2)) + ");\n if (!success) {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n return { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String((() => { const w = relationOptionWarning(relationType, params); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + " };\n})();\n").trim();
1586
+ String(JSON.stringify(params, null, 2)) + ");\n if (!success) {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n return { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String(relationOptionFields(relationType, params, 'warning')) + " };\n})();\n").trim();
1474
1587
  }
1475
1588
  /**
1476
1589
  * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
@@ -1546,9 +1659,18 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1546
1659
  for (const k of ['velocities', 'offsets'])
1547
1660
  if (out[k] !== undefined)
1548
1661
  out[k] = pairs(out[k]);
1662
+ // A trail's single offset is one pair; the studio reads [x, y] and dropped {x, y} with nothing said (TRAILOFF31).
1663
+ const off = out.offset;
1664
+ if (off && typeof off === 'object' && !Array.isArray(off) && typeof off.x === 'number' && typeof off.y === 'number')
1665
+ out.offset = [off.x, off.y];
1549
1666
  return out;
1550
1667
  };
1551
1668
  const out = fix(spec);
1669
+ // rate, start and end drive a trail. Beside bursts they drew nothing and said nothing (EMITCONT31).
1670
+ const trailOnly = ['rate', 'start', 'end'].filter((k) => spec[k] !== undefined);
1671
+ if (trailOnly.length && Array.isArray(spec.bursts) && spec.mode !== 'trail' && spec.target === undefined) {
1672
+ warnings.push(`${trailOnly.join(', ')} ${trailOnly.length > 1 ? 'are' : 'is'} not read by a burst emitter, so ${trailOnly.length > 1 ? 'they had' : 'it had'} no effect: only the bursts' own count is drawn. For particles over time, add bursts, or use mode 'trail' with a target.`);
1673
+ }
1552
1674
  if (Array.isArray(spec.bursts))
1553
1675
  out.bursts = spec.bursts.map((b) => (b && typeof b === 'object' ? fix(b) : b));
1554
1676
  // The studio's default count per burst (ParticleEmitter DEFAULT_LOOK).
@@ -1802,7 +1924,7 @@ const formatted = relations.map(r => ({
1802
1924
  // this tool even though it was wired correctly on the canvas.
1803
1925
  targetId: r.target ?? r.targetId ?? null,
1804
1926
  relationType: r.relation || r.type,
1805
- params: r.params || {}
1927
+ params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
1806
1928
  }));
1807
1929
 
1808
1930
  ({ relations: formatted });
@@ -1822,7 +1944,7 @@ const relations = results.map(r => ({
1822
1944
  // the filter string labelled every edge 'unknown' on an unfiltered query —
1823
1945
  // which is exactly the query you make when exploring an unfamiliar graph.
1824
1946
  relationType: r.relation || ${relationType ? jsLiteral(relationType) : "'unknown'"},
1825
- params: r.params || {}
1947
+ params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
1826
1948
  }));
1827
1949
 
1828
1950
  ({ relations });
@@ -1974,6 +2096,24 @@ function unanimatedTextKeysJs(keys) {
1974
2096
  return ${JSON.stringify(spec)}.filter(function(s) { return !s.names.some(function(n) { return __names.indexOf(n) >= 0; }); }).map(function(s) { return s.key; });
1975
2097
  })()`;
1976
2098
  }
2099
+ /**
2100
+ * An image filter's parameter as a key: keyframe_animate stores it, and a filter does not read a track, so the item
2101
+ * never changes with success:true (FILTKEY31). Asked of the same list, so a studio that starts animating one is believed.
2102
+ */
2103
+ const FILTER_PARAM_KEYS = new Set([...Object.values(FILTER_PARAM_RANGES).flatMap((p) => Object.keys(p)), ...FILTER_TYPES].filter((k) => !['color', 'opacity', 'scale', 'x', 'y', 'rotation', 'size', 'radius', 'width', 'height', 'value', 'amount'].includes(k)));
2104
+ function unanimatedFilterKeysJs(keys) {
2105
+ const filterKeys = [...new Set(keys)].filter((k) => FILTER_PARAM_KEYS.has(k));
2106
+ if (!filterKeys.length)
2107
+ return '[]';
2108
+ return `(function() {
2109
+ let __l = null;
2110
+ try { __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null; } catch (_) { __l = null; }
2111
+ if (!__l || !__l.properties) return [];
2112
+ const __names = __l.properties.map(function(p) { return p && p.name; });
2113
+ return ${JSON.stringify(filterKeys)}.filter(function(k) { return __names.indexOf(k) < 0; });
2114
+ })()`;
2115
+ }
2116
+ const UNANIMATED_FILTER_KEYS_SENTENCE = `' is an image filter setting and not an animatable property, so the key had no effect. Set a fixed value with pinepaper_image_filter or pinepaper_add_filter, and animate opacity or scale for a change over time.'`;
1977
2117
  /** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
1978
2118
  const UNANIMATED_TEXT_KEYS_SENTENCE = `' is not an animatable property in this studio (app.listAnimatableProperties does not list it), so it had no effect: a key animates only where that list names it. Here, set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX. For a skew, key skewX / skewY (degrees) where the studio lists them, else a transform matrix track.'`;
1979
2119
  /**
@@ -1999,12 +2139,6 @@ function keyframeResultNotes(keyframes, { loop = false, append = false, easingAt
1999
2139
  ? `, propertyGaps: ${JSON.stringify(gaps.slice(0, 10))}, gapWarning: ${JSON.stringify(`${gaps.length} keyframe(s) leave out a property other keys animate (${[...new Set(gaps.flatMap((g) => g.missing))].join(', ')}). The engine can break that property's track at such a key; repeat the value on every key — hold it by repeating the previous value.`)}`
2000
2140
  : '';
2001
2141
  const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
2002
- // loop:true repeats in the editor; an export of the measured build played
2003
- // the keyframes ONCE and held (round 7, 2.24). Said where it is set, with
2004
- // the two ways that do export, until the engine's export clock wraps them.
2005
- const loopNote = loop
2006
- ? `, 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.')}`
2007
- : '';
2008
2142
  // SIZEKEY-PAIR (mcp-cloud #28): on a studio before FxTool #262, x (or
2009
2143
  // position) keyed beside fontSize on one text item drifted in an export: the
2010
2144
  // size was applied after the position, and a text resizes around its anchor.
@@ -2016,7 +2150,7 @@ function keyframeResultNotes(keyframes, { loop = false, append = false, easingAt
2016
2150
  const easingNote = easingAt !== 'departure' && first?.easing && first.easing !== 'linear'
2017
2151
  ? `, 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).`)}`
2018
2152
  : '';
2019
- return `${easingNote}${sizekeyPairNote}${loopNote}${gapNote}`;
2153
+ return `${easingNote}${sizekeyPairNote}${gapNote}`;
2020
2154
  }
2021
2155
  function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false, easingAt) {
2022
2156
  const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
@@ -2074,9 +2208,12 @@ String(JSON.stringify(levelKeys)) +
2074
2208
  const textKeyCheck = textKeysJs ? `
2075
2209
  const __ignored = ${textKeysJs};
2076
2210
  if (__ignored.length) __warn.push(__ignored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE});` : '';
2211
+ const filterKeyCheck = `
2212
+ const __filterIgnored = ${unanimatedFilterKeysJs(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))};
2213
+ if (__filterIgnored.length) __warn.push(__filterIgnored.join(', ') + ${UNANIMATED_FILTER_KEYS_SENTENCE});`;
2077
2214
  const easingAtCheck = easingAt !== undefined ? `
2078
2215
  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.');` : '';
2079
- const runtimeWarning = `${textKeysJs ? ', ...(__ignored.length ? { ignoredProperties: __ignored } : {})' : ''}, ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
2216
+ const runtimeWarning = `, ...(${textKeysJs ? '__ignored.length || ' : ''}__filterIgnored.length ? { ignoredProperties: ${textKeysJs ? '__ignored.concat(__filterIgnored)' : '__filterIgnored'} } : {}), ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
2080
2217
  const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `${ID}`;
2081
2218
  return ("\n// Apply keyframe animation to " +
2082
2219
  String(ID) +
@@ -2092,6 +2229,7 @@ String(keyframesJson) +
2092
2229
  ", __opts);\n const __warn = [];" +
2093
2230
  String(easingAtCheck) +
2094
2231
  String(textKeyCheck) +
2232
+ String(filterKeyCheck) +
2095
2233
  "\n const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];\n const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });" +
2096
2234
  String(append ? `
2097
2235
  // Property gaps across the merged track (the same check a single call makes
@@ -2111,8 +2249,7 @@ String(calculatedDuration) +
2111
2249
  ", loop: " +
2112
2250
  String(loop) +
2113
2251
  String(append ? ', append: true' : '') +
2114
- ", ...(__track ? { track: __track } : {})" +
2115
- String(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.' } : {})` : '') + String(timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : '') + String(clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : '') + String(clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : '') + String(__notes) + String(runtimeWarning) + " };\n})();\n").trim();
2252
+ ", ...(__track ? { track: __track } : {})" + String(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.' } : {})` : '') + String(timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : '') + String(clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : '') + String(clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : '') + String(__notes) + String(runtimeWarning) + " };\n})();\n").trim();
2116
2253
  }
2117
2254
  /**
2118
2255
  * Template for executing generators
@@ -2246,6 +2383,29 @@ function withRadiusAxes(props) {
2246
2383
  out.height = radiusY * 2;
2247
2384
  return out;
2248
2385
  }
2386
+ /**
2387
+ * One get_items entry. What create applied is read back here, so an agent can check it: the name a patch addresses
2388
+ * the item by, and scale, rotation and opacity when they are not the default (CROWSCALE30: a create-time scale
2389
+ * rendered exactly and get_items did not show it; PATCHIMG29: an import's name was not shown either). Defaults are
2390
+ * left out, so a plain item's entry stays as small as before.
2391
+ */
2392
+ const GET_ITEMS_ENTRY_JS = ("entry => {\n const it = entry.item || {};\n const out = {\n id: entry.id || entry.itemId,\n type: entry.type,\n position: it.position ? { x: it.position.x, y: it.position.y } : null,\n hasAnimation: !!((it.data?.animationType && it.data.animationType !== 'none') || (Array.isArray(it.data?.keyframes) && it.data.keyframes.length > 0)),\n relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)\n };\n const name = it.name || entry.name || (it.data && it.data.name);\n if (name) out.name = name;\n const r2 = (v) => Math.round(v * 1000) / 1000;\n if (it.scaling && (Math.abs(it.scaling.x - 1) > 1e-6 || Math.abs(it.scaling.y - 1) > 1e-6)) out.scale" +
2393
+ " = { x: r2(it.scaling.x), y: r2(it.scaling.y) };\n if (typeof it.rotation === 'number' && Math.abs(it.rotation) > 1e-6) out.rotation = r2(it.rotation);\n if (typeof it.opacity === 'number' && it.opacity !== 1) out.opacity = r2(it.opacity);\n return out;\n}"
2394
+
2395
+
2396
+
2397
+
2398
+
2399
+
2400
+
2401
+
2402
+
2403
+
2404
+
2405
+
2406
+
2407
+
2408
+ );
2249
2409
  function generateGetItemsCode(filter) {
2250
2410
  // A filter OBJECT is not the same as a filter. `{}` is already handled, but
2251
2411
  // so is `{ type: undefined }` and anything whose keys this function does not
@@ -2270,13 +2430,7 @@ function generateGetItemsCode(filter) {
2270
2430
  return `
2271
2431
  // Get all items
2272
2432
  const entries = app.itemRegistry.getAll();
2273
- const items = entries.map(entry => ({
2274
- id: entry.id || entry.itemId,
2275
- type: entry.type,
2276
- position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
2277
- hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
2278
- relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
2279
- }));
2433
+ const items = entries.map(${GET_ITEMS_ENTRY_JS});
2280
2434
 
2281
2435
  ({ items, count: items.length });
2282
2436
  `.trim();
@@ -2285,13 +2439,7 @@ const items = entries.map(entry => ({
2285
2439
  // Get items with filter
2286
2440
  const entries = app.itemRegistry.getAll();
2287
2441
  const filtered = entries.filter(entry => ${conditions.join(' && ')});
2288
- const items = filtered.map(entry => ({
2289
- id: entry.id || entry.itemId,
2290
- type: entry.type,
2291
- position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
2292
- hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
2293
- relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
2294
- }));
2442
+ const items = filtered.map(${GET_ITEMS_ENTRY_JS});
2295
2443
 
2296
2444
  ({ items, count: items.length });
2297
2445
  `.trim();
@@ -2542,7 +2690,12 @@ function generateClearCanvasCode() {
2542
2690
  String(MEDIA_PURGE) +
2543
2691
  "\n\nif (typeof app.clearCanvas === 'function') {\n app.clearCanvas();\n} else {\n // Fallback for older builds without clearCanvas().\n if (app.textItemGroup) app.textItemGroup.removeChildren();\n if (app.patternGroup) app.patternGroup.removeChildren();\n if (app.itemRegistry) app.itemRegistry.clear();\n if (app.relationRegistry) app.relationRegistry.clear();\n}\n" +
2544
2692
  String(ID_COUNTER_RESET) +
2545
- "\n\nif (app.historyManager) app.historyManager.saveState();\n\nconst remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;\n\n({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });\n"
2693
+ "\n// A rule outlives app.clearCanvas, and would go on running in the next scene against its item ids.\nlet __rulesRemoved = 0;\nif (typeof app.listContraptions === 'function' && typeof app.removeContraption === 'function') {\n app.listContraptions().map((c) => c.id).forEach((id) => { if (app.removeContraption(id)) __rulesRemoved++; });\n}\n\nif (app.historyManager) app.historyManager.saveState();\n\nconst remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;\n\n({ success: true, ...(__rulesRemoved ? { contraptionsRemoved: __rulesRemoved } : {}), removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });\n"
2694
+
2695
+
2696
+
2697
+
2698
+
2546
2699
 
2547
2700
 
2548
2701
 
@@ -3440,7 +3593,7 @@ String(JSON.stringify(validated.presetId)) +
3440
3593
  const code = stepNoTarget
3441
3594
  ? `(function() {
3442
3595
  if (!${STEP_ACTIVE_TARGETLESS_JS}) return { success: false, relationType: 'on_event_step_active', error: ${jsLiteral(stepNoTarget)} };
3443
- return ${built.trim().replace(/;$/, '')};
3596
+ ${asReturningBody(built)}
3444
3597
  })();`
3445
3598
  : built;
3446
3599
  // NO TARGET (event reactions like on_event_play_sound, self-relations): the
@@ -4120,7 +4273,7 @@ if (typeof _gen === 'function') {
4120
4273
  })();`);
4121
4274
  }
4122
4275
  // Establish relations
4123
- for (const relation of relations) {
4276
+ for (const [relationIndex, relation] of relations.entries()) {
4124
4277
  const { source, target, type, params = {} } = relation;
4125
4278
  const paramsStr = JSON.stringify(params);
4126
4279
  const specProblem = relationSpecProblem(type, params, target, 'no-page');
@@ -4150,7 +4303,10 @@ String(jsLiteral(type)) +
4150
4303
  String(jsLiteral(source)) +
4151
4304
  ", target: " +
4152
4305
  String(jsLiteral(target)) +
4153
- ", type: " + String(jsLiteral(type)) + " });\n } else {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(type))) + ";\n (results.relationsRefused = results.relationsRefused || []).push({ source: " + String(jsLiteral(source)) + ", target: " + String(jsLiteral(target)) + ", type: " + String(jsLiteral(type)) + ", reason: __why || 'the studio refused it' });\n results.success = false;\n 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.';\n }\n }\n})();\n"));
4306
+ ", type: " +
4307
+ String(jsLiteral(type)) +
4308
+ " });\n const __o = { _: 0" +
4309
+ String(relationOptionFields(type, params, 'warning')) + " };\n if (__o.warning) (results.optionWarnings = results.optionWarnings || []).push(__o.warning);\n if (__o.ignoredOptions) (results.ignoredOptions = results.ignoredOptions || []).push.apply(results.ignoredOptions, __o.ignoredOptions.map(function(k) { return 'relations[" + String(relationIndex) + "].params.' + k; }));\n } else {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(type))) + ";\n (results.relationsRefused = results.relationsRefused || []).push({ source: " + String(jsLiteral(source)) + ", target: " + String(jsLiteral(target)) + ", type: " + String(jsLiteral(type)) + ", reason: __why || 'the studio refused it' });\n results.success = false;\n 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.';\n }\n }\n})();\n"));
4154
4310
  }
4155
4311
  // Apply animations
4156
4312
  for (const animation of animations) {
@@ -5120,7 +5276,7 @@ const targetId = ${animItemRef};
5120
5276
  const item = app.getItemById(targetId);
5121
5277
  if (!item) throw new Error('Item not found: ' + targetId);
5122
5278
  app.animate(item, { animationType: ${JSON.stringify(String(op.animationType))}, ...${animOpts} });
5123
- return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationType))}${(() => { const w = animationOptionWarning(op.animationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()} };
5279
+ return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationType))}${(() => { const w = animationOptionWarning(op.animationOptions); return w ? `, ignoredOptions: ${jsLiteral(unreadKeys(ANIMATION_OPTION_KEYS, op.animationOptions))}, warning: ${jsLiteral(w)}` : ''; })()} };
5124
5280
  `;
5125
5281
  case 'relation':
5126
5282
  const srcRef = batchRef(op.sourceId);
@@ -5181,7 +5337,7 @@ String(type) +
5181
5337
  ", sourceIds: __failed, error: " +
5182
5338
  String(op.update === true ? '__why' : `'the studio refused the ' + ${type} + ' relation for ' + __failed.join(', ') + ' — nothing was bound' + (__why ? ': ' + __why : '.')`) +
5183
5339
  " };\n}\nreturn { success: true, relationType: " +
5184
- String(type) + ", " + String(op.update === true ? 'updated' : 'bound') + ": __done, targetId" + String(glob ? ', count: __members.length' : '') + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions, ['index', 'count', 'pattern']); return w ? `, optionWarning: ${jsLiteral(w)}` : ''; })()) + ", ...(__failed.length ? { failed: __failed, warning: __failed.length + ' of ' + __members.length + ' were not " + String(op.update === true ? 'changed' : 'bound') + ": ' + __failed.join(', ') } : {}) };\n");
5340
+ String(type) + ", " + String(op.update === true ? 'updated' : 'bound') + ": __done, targetId" + String(glob ? ', count: __members.length' : '') + String(relationOptionFields(op.relationType, op.relationOptions, 'optionWarning', ['index', 'count', 'pattern'])) + ", ...(__failed.length ? { failed: __failed, warning: __failed.length + ' of ' + __members.length + ' were not " + String(op.update === true ? 'changed' : 'bound') + ": ' + __failed.join(', ') } : {}) };\n");
5185
5341
  }
5186
5342
  return ("\n" +
5187
5343
  String(stepGuard) +
@@ -5200,7 +5356,7 @@ String(relOpts) +
5200
5356
  ");\n// NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false\n// when it refuses — spring_follow \"not loaded yet\" — and this op returned\n// { relationId: false } with nothing marking it failed, so the batch said\n// success over a relation that does not exist.\nif (!relationId) {\n const __why = " +
5201
5357
  String(relationRefusalReasonJs(jsLiteral(op.relationType))) +
5202
5358
  ";\n return { success: false, relationId: false, sourceId, targetId, relationType: " +
5203
- String(JSON.stringify(op.relationType)) + ",\n error: 'the studio refused the ' + " + String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + " };\n");
5359
+ String(JSON.stringify(op.relationType)) + ",\n error: 'the studio refused the ' + " + String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String(relationOptionFields(op.relationType, op.relationOptions, 'warning')) + " };\n");
5204
5360
  case 'delete':
5205
5361
  const delRef = batchRef(op.itemId);
5206
5362
  return `
@@ -5288,8 +5444,9 @@ return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget}
5288
5444
  kfExtra.push(`timeUnits: ${jsLiteral(kfUnits)}`);
5289
5445
  const kfExtraStr = kfExtra.length > 0 ? `, ${kfExtra.join(', ')}` : '';
5290
5446
  const kfTextKeysJs = unanimatedTextKeysJs((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})));
5291
- const kfTextCheck = kfTextKeysJs ? `\nconst _kfIgnored = ${kfTextKeysJs};` : '';
5292
- const kfIgnored = kfTextKeysJs ? `, ...(_kfIgnored.length ? { ignoredProperties: _kfIgnored, warning: _kfIgnored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE} } : {})` : '';
5447
+ const kfFilterKeysJs = unanimatedFilterKeysJs((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})));
5448
+ const kfTextCheck = `\nconst _kfIgnored = ${kfTextKeysJs || '[]'};\nconst _kfFilterIgnored = ${kfFilterKeysJs};`;
5449
+ const kfIgnored = `, ...(_kfIgnored.length || _kfFilterIgnored.length ? { ignoredProperties: _kfIgnored.concat(_kfFilterIgnored), warning: [_kfIgnored.length ? _kfIgnored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE} : '', _kfFilterIgnored.length ? _kfFilterIgnored.join(', ') + ${UNANIMATED_FILTER_KEYS_SENTENCE} : ''].filter(Boolean).join(' ') } : {})`;
5293
5450
  return ("\nconst targetId = " +
5294
5451
  String(kfItemRef) +
5295
5452
  ";\nconst _target = (app.getItemById && app.getItemById(targetId))\n || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(targetId));\nif (!_target) {\n return { success: false, itemId: targetId, error: 'Item not found: ' + targetId };\n}\nconst _kfItem = _target.item || _target;\nconst _prevKeys = (_kfItem.data && Array.isArray(_kfItem.data.keyframes)) ? _kfItem.data.keyframes.length : 0;\napp.addAnimation(targetId, " +
@@ -5429,7 +5586,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5429
5586
  }
5430
5587
  generateAgentExport(input) {
5431
5588
  const validated = AgentExportInputSchema.parse(input);
5432
- const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, alphaQuantizer, deterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
5589
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, audioCodec, transparent, alphaQuantizer, deterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
5433
5590
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
5434
5591
  const videoEncodeOpts = {};
5435
5592
  // A RANGE of the timeline (FxTool #250): frames for t in [startTime, startTime + duration), each exactly the frame
@@ -5451,8 +5608,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5451
5608
  videoEncodeOpts.minBitrate = minBitrate;
5452
5609
  if (bitrateMode !== undefined)
5453
5610
  videoEncodeOpts.bitrateMode = bitrateMode;
5454
- // Byte-identical repeats (FxTool 3b335567): the container timestamps are
5455
- // the only bytes that varied, and this pins them.
5611
+ // B45 (FxTool #288): 'aac' makes the studio refuse an MP4 whose soundtrack would be Opus (code
5612
+ // audio_codec_unavailable) rather than write a file WhatsApp rejects. 'any' is the engine's default, so not sent.
5613
+ if (audioCodec === 'aac')
5614
+ videoEncodeOpts.audioCodec = 'aac';
5615
+ // Determinism (B31, the contract signed off in FxTool #290): identical encoder
5616
+ // input and a container identical apart from the encoded samples; the file
5617
+ // itself repeats only where the studio reports bitExact: true.
5456
5618
  if (deterministic)
5457
5619
  videoEncodeOpts.deterministic = true;
5458
5620
  // Motion blur (FxTool #55): the engine forwards it on every mp4/webm path
@@ -6086,9 +6248,10 @@ String(videoDuration) +
6086
6248
  ".264 needs even dimensions, so\n // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The\n // result's dimensions become what the file IS, and fidelity says why.\n const __vd = __vr && __vr.dimensions;\n if (result && result.success && __vd && Array.isArray(__vd.requested) && Array.isArray(__vd.output)\n && (__vd.requested[0] !== __vd.output[0] || __vd.requested[1] !== __vd.output[1])) {\n result.dimensions = { width: __vd.output[0], height: __vd.output[1] };\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'dimensions_rounded',\n message: 'the file is ' + __vd.outpu" +
6087
6249
  "t[0] + 'x' + __vd.output[1] + ', not the requested ' + __vd.requested[0] + 'x' + __vd.requested[1] + ': H.264 needs even dimensions, so an odd side was rounded up by 1 px (the extra row / column is background). For an exact-pixel spec, use an even canvas size, or crop the extra line when re-encoding (ffmpeg -vf crop=' + __vd.requested[0] + ':' + __vd.requested[1] + ':0:0).' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n" +
6088
6250
  String(deterministic ? (" // DETERMINISTIC: the container timestamps are pinned either way; the PIXELS\n // are only identical run to run on the software encoder, because the\n // browser may pick a different hardware backend each time (FxTool\n // e34ae03d: 92/255 between backends, 2/255 run to run on hardware). The\n // engine requests software and reports what it got.\n // Where the studio says outright whether the file repeats (bitExact, FxTool\n // PR #31: it test-encodes and keeps whichever encoder does — often the\n // HARDWARE one on a Mac, since Chrome's software H.264 is not repeatable at\n // some sizes), that answer decides; the encoder name is only the fallback\n // for studios that do not r" +
6089
- "eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked): the pixels may differ slightly between runs. Do not cache this file by hash; a different frame size may repeat.' }]);\n if (result.fidelity.note) delete " +
6090
- "result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.stringify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'\n : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between r" +
6091
- "uns if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n }\n"
6251
+ "eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n ...(__vr && __vr.cause ? { cause: __vr.cause } : {}),\n message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked' + (__vr && __vr.cause === 'h264-threaded' ? ': the software H.264 encoder is multi-threaded and " +
6252
+ "takes no fixed quantizer' : '') + '). What still holds: the frames fed to the encoder are identical (compare frameDigest, not sha256), and the container is identical apart from the encoded samples. Do not cache this file by hash; export WebM for a byte-identical file.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.str" +
6253
+ "ingify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'\n : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between runs if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n }\n"
6254
+
6092
6255
 
6093
6256
 
6094
6257
 
@@ -6174,9 +6337,14 @@ String((() => {
6174
6337
  String(JSON.stringify(format)) +
6175
6338
  ") !== -1) {\n try {\n const __hud = app.screenSpaceItems() || [];\n if (__hud.length) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'screen_space_ignored', message: __hud.length + ' screen-space (HUD) item(s) are written at their frame coordinates in world space: " +
6176
6339
  String(format) +
6177
- " has no camera frame, so they do not stay fixed under the camera move. Export mp4, webm, gif or png to keep them fixed.' }]);\n }\n } catch (_) { /* the export stands */ }\n }\n // WHICH STUDIO MADE THIS FILE (D92): a service worker can serve a stale\n // build right after a deploy, and a retest then judges old code. The\n // engine's own identity, version + build sha, rides on every export reply.\n try { if (result && typeof result === 'object' && app.buildId) result.studioBuild = app.buildId; } catch (_) { /* older studio */ }\n // A STALE STUDIO (D92): the file was made by an old build. Said in the\n // fidelity warnings the reply shows, not only as a field.\n try {\n " +
6178
- " const __bs = app.buildStatus ? app.buildStatus() : null;\n if (result && typeof result === 'object' && __bs) {\n result.liveBuild = __bs.liveBuildId;\n if (__bs.stale) {\n result.stale = true;\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'stale_build', message: 'this studio is running an old build (' + __bs.buildId + '; the live build is ' + __bs.liveBuildId + '): this file was made by the old code, so it may differ from what the live build makes. pinepaper_clear_canvas (or pinepaper_agent_start_job) loads the live build; it empties the canvas, so rebuild the scene a" +
6179
- "nd export again if the difference matters.' }]);\n }\n }\n } catch (_) { /* older studio */ }\n // RELIGHT (FxTool D5): applied to png / pdf / mp4 / webm, not gif / apng\n // or the vector formats; and it is what makes a video export slow.\n if (result && result.success && typeof app.getRelight === 'function') {\n try {\n const __rl = app.getRelight();\n if (__rl && __rl.enabled) {\n result.fidelity = result.fidelity || { warnings: [] };\n if (['png', 'jpg', 'webp', 'pdf', 'mp4', 'webm'].indexOf(" +
6340
+ " has no camera frame, so they do not stay fixed under the camera move. Export mp4, webm, gif or png to keep them fixed.' }]);\n }\n } catch (_) { /* the export stands */ }\n }\n // A SOUND PAST THE END (SNDTAIL31): placed, accepted, and not in the file. The video's length decides, as it should; it is said.\n if (result && result.success && ['mp4', 'webm'].indexOf(" +
6341
+ String(JSON.stringify(format)) +
6342
+ ") !== -1 && app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {\n try {\n const __end = (result.video && result.video.durationS) || " +
6343
+ String(JSON.stringify(duration ?? null)) +
6344
+ ";\n const __late = __end ? app.itemRegistry.getAll().filter(function (e) { const d = (e.item || e).data; return d && d.isSoundChip && typeof d.clipStartTime === 'number' && d.clipStartTime >= __end; }) : [];\n if (__late.length) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'sound_after_end', message: __late.length + ' sound(s) start at or after the end of this ' + __end + ' s export, so they are not in the file: ' + __late.map(function (e) { return (e.item || e).data.registryId + ' at ' + (e.item || e).data.clipStartTime + ' s'; }).slice(0, 10).join(', ') + '.' }]);\n }" +
6345
+ "\n } catch (_) { /* the export stands */ }\n }\n // WHICH STUDIO MADE THIS FILE (D92): a service worker can serve a stale\n // build right after a deploy, and a retest then judges old code. The\n // engine's own identity, version + build sha, rides on every export reply.\n try { if (result && typeof result === 'object' && app.buildId) result.studioBuild = app.buildId; } catch (_) { /* older studio */ }\n // A STALE STUDIO (D92): the file was made by an old build. Said in the\n // fidelity warnings the reply shows, not only as a field.\n try {\n const __bs = app.buildStatus ? app.buildStatus() : null;\n if (result && typeof result === 'object' && __bs) {\n result.liveB" +
6346
+ "uild = __bs.liveBuildId;\n if (__bs.stale) {\n result.stale = true;\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'stale_build', message: 'this studio is running an old build (' + __bs.buildId + '; the live build is ' + __bs.liveBuildId + '): this file was made by the old code, so it may differ from what the live build makes. pinepaper_clear_canvas (or pinepaper_agent_start_job) loads the live build; it empties the canvas, so rebuild the scene and export again if the difference matters.' }]);\n }\n }\n } catch (_) { /* older studio */ }\n // RELIGHT (FxTool D5): appl" +
6347
+ "ied to png / pdf / mp4 / webm, not gif / apng\n // or the vector formats; and it is what makes a video export slow.\n if (result && result.success && typeof app.getRelight === 'function') {\n try {\n const __rl = app.getRelight();\n if (__rl && __rl.enabled) {\n result.fidelity = result.fidelity || { warnings: [] };\n if (['png', 'jpg', 'webp', 'pdf', 'mp4', 'webm'].indexOf(" +
6180
6348
  String(JSON.stringify(format)) +
6181
6349
  ") === -1) {\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'relight_not_applied', message: 'a relight rig is set, but ' + " +
6182
6350
  String(jsLiteral(format)) +
@@ -7221,6 +7389,13 @@ String(jsLiteral(format)) +
7221
7389
 
7222
7390
 
7223
7391
 
7392
+
7393
+
7394
+
7395
+
7396
+
7397
+
7398
+
7224
7399
 
7225
7400
 
7226
7401
 
@@ -8840,7 +9015,7 @@ String(validated.priority || 0) +
8840
9015
  "\n });\n // The definition as given, kept on the page so a later call can read it back (pinepaper_lint_scene\n // customRelations): the engine keeps the compiled rule, not the text an agent would edit.\n window.__ppCustomRelations = window.__ppCustomRelations || {};\n window.__ppCustomRelations[" +
8841
9016
  String(jsLiteral(validated.name)) +
8842
9017
  "] = " +
8843
- String(jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction })) +
9018
+ String(jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction, reads: customRelationReads(validated.params, validated.computeFunction, validated.applyFunction) })) +
8844
9019
  ";\n return { success: true, relationName: " +
8845
9020
  String(jsLiteral(validated.name)) +
8846
9021
  ", message: 'Custom relation registered successfully' };\n } catch (error) {\n return { success: false, error: error.message };\n }\n})();\n"
@@ -9232,7 +9407,9 @@ String(jsLiteral(templateId)) +
9232
9407
  ).trim();
9233
9408
  }
9234
9409
  generateImportImage(input) {
9235
- const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset, name } = input;
9410
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset, name, opacity, blendMode, scale, loop } = input;
9411
+ const look = { ...(opacity !== undefined ? { opacity } : {}), ...(blendMode !== undefined ? { blendMode } : {}), ...(scale !== undefined ? { scale } : {}) };
9412
+ const hasLook = Object.keys(look).length > 0;
9236
9413
  // IMPORTSHADOW: the shadow keys, applied through the engine's own modify
9237
9414
  // door once the image is placed (and masked) — the route modify_item takes.
9238
9415
  const shadow = {};
@@ -9254,6 +9431,7 @@ String(jsLiteral(templateId)) +
9254
9431
  maxWidth !== undefined ? `maxWidth: ${maxWidth}` : '',
9255
9432
  maxHeight !== undefined ? `maxHeight: ${maxHeight}` : '',
9256
9433
  nativeSize ? 'nativeSize: true' : '',
9434
+ loop !== undefined ? `loop: ${JSON.stringify(loop)}` : '',
9257
9435
  ].filter(Boolean);
9258
9436
  const optsLiteral = optParts.length ? `{ ${optParts.join(', ')} }` : '{}';
9259
9437
  return ("\n// Import image from URL\n(async function() {\n if (!app.imageTools) {\n return { error: 'Image tools not available. Make sure PinePaper Studio is loaded.' };\n }\n try {\n // FETCH IT OURSELVES FIRST, to find out WHY when it fails.\n //\n // uploadFromURL sets img.src and rejects with a bare \"Failed to load image\n // from URL\" — an <img> onerror genuinely carries no reason, so that message\n // is the browser's limit rather than the engine being unhelpful. fetch()\n // has the reason: a status code, a CORS rejection, a refused connection, a\n // DNS failure. A caller cannot debug what the tool will not show them.\n //\n // The bytes then go in as a DA" +
@@ -9285,6 +9463,16 @@ String(mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask b
9285
9463
  if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
9286
9464
  placed = masked;
9287
9465
  ` : '') +
9466
+ String(hasLook ? ` // opacity, blendMode, scale: read by modify, on what is drawn. They were not import arguments (OPACIMP31, BLENDIMP31, IMGSCALE31).
9467
+ let __look = null;
9468
+ {
9469
+ let __ok = false;
9470
+ try { __ok = app.modifyItem(itemId, ${JSON.stringify(look)}) !== false; } catch (_) { __ok = false; }
9471
+ __look = __ok
9472
+ ? { applied: true, opacity: placed.opacity, blendMode: placed.blendMode }
9473
+ : { applied: false, warning: 'the image was imported but ' + ${jsLiteral(Object.keys(look).join(', '))} + ' was not applied: set it with pinepaper_modify_item on ' + itemId + '.' };
9474
+ }
9475
+ ` : '') +
9288
9476
  String(hasShadow ? ` // The shadow goes on what is DRAWN, after a mask has replaced the raster.
9289
9477
  let __shadow = null;
9290
9478
  {
@@ -9307,6 +9495,8 @@ String(name ? `\n name: ${JSON.stringify(name)},` : '') +
9307
9495
  "\n bounds: { x: b.x, y: b.y, width: b.width, height: b.height }" +
9308
9496
  String(mask ? `,\n mask: ${jsLiteral(mask)}` : '') +
9309
9497
  String(hasShadow ? ',\n shadow: __shadow' : '') +
9498
+ String(loop !== undefined ? ",\n playback: (raster && raster.data && raster.data.animatedImage && raster.data.animatedImage.loop !== undefined) ? { applied: true, plays: raster.data.animatedImage.loop } : { applied: false, warning: 'loop was not applied: this image does not animate in this studio. Animated GIF and WebP do.' }" : '') +
9499
+ String(hasLook ? ',\n look: __look' : '') +
9310
9500
  ",\n // Stored smaller than its source (the studio's cap): said, with the\n // way to keep every pixel. The studio reports native size where it can.\n ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)\n ? { stored: { width: entry.width, height: entry.height }, native: { width: entry.nativeWidth, height: entry.nativeHeight },\n note: 'the studio stored this image at ' + entry.width + 'x' + entry.height + ' (its source is ' + entry.nativeWidth + 'x' + entry.nativeHeight + '). Pass maxEdge: \"native\" to keep every pixel.' }\n : {})\n };\n } catch (e) {\n return { error: 'Failed to import image: ' + e.message };\n " +
9311
9501
  " }\n})();\n"
9312
9502
 
@@ -9398,6 +9588,12 @@ String(hasShadow ? ',\n shadow: __shadow' : '') +
9398
9588
 
9399
9589
 
9400
9590
 
9591
+
9592
+
9593
+
9594
+
9595
+
9596
+
9401
9597
 
9402
9598
 
9403
9599
 
@@ -12761,16 +12957,20 @@ String(diagJson) +
12761
12957
  String(optsJson) +
12762
12958
  ");\n // THE RELATIONS AS BOUND, so a later agent can find the one to edit without exporting the scene to read it:\n // from, to, type and params, capped. And the custom relation definitions registered on this page, as given.\n const __rels = [];\n let __relCount = 0;\n try {\n const __rr = app.relationRegistry;\n const __active = __rr && typeof __rr.queryActive === 'function' ? __rr.queryActive() : [];\n __active.forEach((e) => (e.associations || []).forEach((a) => {\n __relCount++;\n if (__rels.length >= " +
12763
12959
  String(RELATIONS_READBACK_CAP) +
12764
- ") return;\n const __name = e.item && e.item.data && e.item.data.name;\n __rels.push({ from: e.itemId, ...(__name ? { fromName: __name } : {}), to: a.target === undefined ? null : a.target, type: a.relation, params: a.params || {} });\n }));\n } catch (e) { /* the relations are an addition; the lint stands without them */ }\n // CUSTOM RELATIONS THIS SCENE USES (#43): one registered before a clear_canvas stayed in the read-back of the scene\n // after it. Those in use are listed; the rest are named in registeredUnused. Their source (computeFunction /\n // applyFunction) only with withSource: true, since an edit of params never needs it.\n const __usedTypes = new Set();\n " +
12765
- "try { const __rr2 = app.relationRegistry; (__rr2 && typeof __rr2.queryActive === 'function' ? __rr2.queryActive() : []).forEach((e) => (e.associations || []).forEach((a) => __usedTypes.add(a.relation))); } catch (e) { /* none known */ }\n const __allCustom = (typeof window !== 'undefined' && window.__ppCustomRelations) ? Object.values(window.__ppCustomRelations) : [];\n const __strip = (d) => { if (" +
12960
+ ") return;\n const __name = e.item && e.item.data && e.item.data.name;\n __rels.push({ from: e.itemId, ...(__name ? { fromName: __name } : {}), to: a.target === undefined ? null : a.target, type: a.relation, params: " +
12961
+ String(RELATION_PUBLIC_PARAMS_JS) +
12962
+ "(a.params) });\n }));\n } catch (e) { /* the relations are an addition; the lint stands without them */ }\n // CUSTOM RELATIONS THIS SCENE USES (#43): one registered before a clear_canvas stayed in the read-back of the scene\n // after it. Those in use are listed; the rest are named in registeredUnused. Their source (computeFunction /\n // applyFunction) only with withSource: true, since an edit of params never needs it.\n const __usedTypes = new Set();\n try { const __rr2 = app.relationRegistry; (__rr2 && typeof __rr2.queryActive === 'function' ? __rr2.queryActive() : []).forEach((e) => (e.associations || []).forEach((a) => __usedTypes.add(a.relation))); } catch (e) { /* none kno" +
12963
+ "wn */ }\n const __allCustom = (typeof window !== 'undefined' && window.__ppCustomRelations) ? Object.values(window.__ppCustomRelations) : [];\n const __strip = (d) => { if (" +
12766
12964
  String(input.withSource === true ? 'true' : 'false') +
12767
12965
  ") return d; const o = Object.assign({}, d); delete o.computeFunction; delete o.applyFunction; return o; };\n const __custom = __allCustom.filter((d) => __usedTypes.has(d.name)).map(__strip);\n const __unusedCustom = __allCustom.filter((d) => !__usedTypes.has(d.name)).map((d) => d.name);\n // HOW TO CHANGE IT (#43): an editing agent needs the op, not the schemas. Each relation comes with the batch op that\n // changes it; a group bound by a name pattern (\"dot-*\", recorded in each member's params) comes once, with the ops\n // that add and remove a member; a custom motion with the call that redefines it.\n const __edit = { hint: 'To change this scene, send the ops below (your values " +
12768
- "in relationOptions) in ONE pinepaper_agent_batch_execute call; a custom motion changes by sending its pinepaper_register_custom_relation call again.', groups: [], relations: [], customRelations: [] };\n try {\n const __byPat = new Map();\n __rels.forEach((r) => {\n const pat = r.params && r.params.pattern;\n if (typeof pat === 'string') {\n const k = pat + '|' + r.type + '|' + r.to;\n if (!__byPat.has(k)) __byPat.set(k, { pattern: pat, type: r.type, to: r.to, members: [] });\n __byPat.get(k).members.push(r);\n return;\n }\n __edit.relations.push({ from: r.from, ...(r.fromName ? { fromName: r.fromName } : {}), to: r.to, relationType: r." +
12769
- "type,\n change: { type: 'relation', sourceId: r.fromName || r.from, ...(r.to != null ? { targetId: r.to } : {}), relationType: r.type, update: true, relationOptions: r.params } });\n });\n __byPat.forEach((g) => {\n const base = Object.assign({}, g.members[0].params); delete base.index; delete base.count; delete base.pattern;\n const names = g.members.map((m) => m.fromName).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));\n const prefix = g.pattern.split('*')[0];\n const nums = names.map((n) => Number(n.slice(prefix.length))).filter((n) => Number.isFinite(n));\n const next = prefix + (nums.length ? Math.max.apply(null, " +
12770
- "nums) + 1 : g.members.length + 1);\n const firstEntry = app.itemRegistry.get(g.members[0].from);\n const bind = { type: 'relation', sourceId: g.pattern, ...(g.to != null ? { targetId: g.to } : {}), relationType: g.type, relationOptions: base };\n __edit.groups.push({ pattern: g.pattern, relationType: g.type, members: g.members.length, ...(names.length ? { first: names[0], last: names[names.length - 1] } : {}), relationOptions: base,\n change: Object.assign({}, bind, { update: true }),\n addMember: [{ type: 'create', itemType: (firstEntry && firstEntry.type) || 'circle', position: [0, 0], properties: { name: next } }, bind],\n ...(names.length ? { removeM" +
12771
- "ember: [{ type: 'delete', itemId: names[names.length - 1] }, bind] } : {}) });\n });\n __custom.forEach((d) => __edit.customRelations.push({ name: d.name, change: { tool: 'pinepaper_register_custom_relation', args: d } }));\n } catch (e) { /* the read-back stands without the edit ops */ }\n if (!" +
12966
+ "in relationOptions) in ONE pinepaper_agent_batch_execute call; a custom motion changes by sending its pinepaper_register_custom_relation call again (withSource: true gives the complete call).', groups: [], relations: [], customRelations: [] };\n try {\n const __byPat = new Map();\n __rels.forEach((r) => {\n const pat = r.params && r.params.pattern;\n if (typeof pat === 'string') {\n const k = pat + '|' + r.type + '|' + r.to;\n if (!__byPat.has(k)) __byPat.set(k, { pattern: pat, type: r.type, to: r.to, members: [] });\n __byPat.get(k).members.push(r);\n return;\n }\n __edit.relations.push({ from: r.from, ...(r.fromName ? { fromName: r.f" +
12967
+ "romName } : {}), to: r.to, relationType: r.type,\n change: { type: 'relation', sourceId: r.fromName || r.from, ...(r.to != null ? { targetId: r.to } : {}), relationType: r.type, update: true, relationOptions: r.params } });\n });\n __byPat.forEach((g) => {\n const base = Object.assign({}, g.members[0].params); delete base.index; delete base.count; delete base.pattern;\n const names = g.members.map((m) => m.fromName).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));\n const prefix = g.pattern.split('*')[0];\n const nums = names.map((n) => Number(n.slice(prefix.length))).filter((n) => Number.isFinite(n));\n const next = pr" +
12968
+ "efix + (nums.length ? Math.max.apply(null, nums) + 1 : g.members.length + 1);\n const firstEntry = app.itemRegistry.get(g.members[0].from);\n const bind = { type: 'relation', sourceId: g.pattern, ...(g.to != null ? { targetId: g.to } : {}), relationType: g.type, relationOptions: base };\n __edit.groups.push({ pattern: g.pattern, relationType: g.type, members: g.members.length, ...(names.length ? { first: names[0], last: names[names.length - 1] } : {}), relationOptions: base,\n change: Object.assign({}, bind, { update: true }),\n addMember: [{ type: 'create', itemType: (firstEntry && firstEntry.type) || 'circle', position: [0, 0], properties: { name: next } }, b" +
12969
+ "ind],\n ...(names.length ? { removeMember: [{ type: 'delete', itemId: names[names.length - 1] }, bind] } : {}) });\n });\n // The register call requires its source, so without withSource there is no call to offer: an op that can only be\n // refused is not one to send. reads is the server's own working-out from that source, not an argument of the call.\n __custom.forEach((d) => {\n const args = Object.assign({}, d); delete args.reads;\n __edit.customRelations.push(" +
12970
+ String(input.withSource === true ? "{ name: d.name, change: { tool: 'pinepaper_register_custom_relation', args } }" : "args") +
12971
+ ");\n });\n } catch (e) { /* the read-back stands without the edit ops */ }\n if (!" +
12772
12972
  String(input.withSource === true ? 'true' : 'false') +
12773
- " && __custom.length) __edit.customHint = 'custom relation source is left out; pass withSource: true to read the current computeFunction / applyFunction before rewriting one.';\n // A GROUP IS LISTED ONCE (#43): members bound by a name pattern repeated the same relation once per item in\n // `relations`; they are in edit.groups, and counted here.\n const __loose = __rels.filter((r) => !(r.params && typeof r.params.pattern === 'string'));\n const __grouped = __rels.length - __loose.length;\n const __editOut = __edit.groups.length || __edit.relations.length || __edit.customRelations.length ? { edit: __edit } : {};\n if (" +
12973
+ " && __custom.length) __edit.customHint = 'custom relations are listed without their source, and the call that redefines one needs it: pass withSource: true to get each complete pinepaper_register_custom_relation call.';\n // A GROUP IS LISTED ONCE (#43): members bound by a name pattern repeated the same relation once per item in\n // `relations`; they are in edit.groups, and counted here.\n const __loose = __rels.filter((r) => !(r.params && typeof r.params.pattern === 'string'));\n const __grouped = __rels.length - __loose.length;\n const __editOut = __edit.groups.length || __edit.relations.length || __edit.customRelations.length ? { edit: __edit } : {};\n if (" +
12774
12974
  String(input.only === 'edit' ? 'true' : 'false') +
12775
12975
  ") {\n return Object.assign({ success: true }, __editOut.edit ? __editOut : { edit: { hint: 'nothing to change: this scene binds no relations.' } },\n __unusedCustom.length ? { registeredUnused: __unusedCustom } : {});\n }\n // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the\n // frame edge). Passed through when the studio reports them; an older studio has none to pass.\n return { success: true, density: result.density, suggestions: result.suggestions,\n ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}),\n relations: __loose, ...(__grouped ? { groupedRelations: __grouped" +
12776
12976
  ", groupedNote: 'relations bound by a name pattern are listed once per group in edit.groups' } : {}),\n ...(__relCount > __rels.length ? { relationsShown: __rels.length, relationCount: __relCount } : {}),\n ...(__custom.length ? { customRelations: __custom } : {}),\n ...(__unusedCustom.length ? { registeredUnused: __unusedCustom } : {}),\n ...__editOut };\n})();"
@@ -12836,6 +13036,7 @@ String(input.only === 'edit' ? 'true' : 'false') +
12836
13036
 
12837
13037
 
12838
13038
 
13039
+
12839
13040
 
12840
13041
 
12841
13042
  ).trim();
@@ -13385,7 +13586,7 @@ String(id) +
13385
13586
  String(id) +
13386
13587
  ", beats: " +
13387
13588
  String(input.beats.length) +
13388
- ", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };\n" +
13589
+ ", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final, ...(__r.defaultsUsed ? { defaultsUsed: __r.defaultsUsed } : {}) };\n" +
13389
13590
  String(input.sound ? (" // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —\n // pop, launch, contact, shake — loud as the moment is strong. A sound that\n // does not place is reported; it never undoes the motion.\n const __map = Object.assign({ pop: 'pop', whoosh: 'whoosh', contact: 'tom', shake: 'hat' }, " +
13390
13591
  String(S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})) +
13391
13592
  ");\n const __gain = " +
@@ -16094,6 +16295,8 @@ String(op) +
16094
16295
  return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
16095
16296
  })();`.trim();
16096
16297
  }
16298
+ if (input.action !== 'pulse')
16299
+ return generateContraptionCode(input);
16097
16300
  // pulse — a runtime fire, not a scene edit (no history save)
16098
16301
  const eventIdJson = JSON.stringify(input.eventId);
16099
16302
  const payloadJson = JSON.stringify(input.payload ?? null);
@@ -16108,6 +16311,78 @@ String(op) +
16108
16311
  })();`.trim();
16109
16312
  }
16110
16313
  }
16314
+ /**
16315
+ * Rules, variables and bound text (the studio's contraption engine). Each action asks for the method it calls, so a
16316
+ * studio without it refuses by name instead of a TypeError. The engine's custom action is not offered: it needs a
16317
+ * function, which JSON cannot carry.
16318
+ */
16319
+ function generateContraptionCode(input) {
16320
+ const refuse = (method) => `if (typeof app.${method} !== 'function') {
16321
+ return { success: false, action: ${jsLiteral(input.action)}, error: 'this studio has no contraption engine (app.${method}): update PinePaper Studio' };
16322
+ }`;
16323
+ const scope = jsLiteral(input.scope ?? 'default');
16324
+ const key = jsLiteral(input.key ?? '');
16325
+ const body = () => {
16326
+ switch (input.action) {
16327
+ case 'add_contraption': {
16328
+ const targets = [...new Set((input.actions ?? []).flatMap((a) => ('targetId' in a ? [a.targetId] : [])))];
16329
+ const config = {
16330
+ ...(input.contraptionId ? { id: input.contraptionId } : {}),
16331
+ sourceEvent: input.sourceEvent,
16332
+ actions: input.actions,
16333
+ ...(input.condition ? { condition: input.condition } : {}),
16334
+ ...(input.thenTrigger ? { thenTrigger: input.thenTrigger } : {}),
16335
+ ...(input.elseTrigger ? { elseTrigger: input.elseTrigger } : {}),
16336
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
16337
+ };
16338
+ return `${refuse('addContraption')}
16339
+ const contraptionId = app.addContraption(${jsLiteral(config)});
16340
+ const missing = ${jsLiteral(targets)}.filter((id) => !app.getItemById(id));
16341
+ return { success: true, action: 'add_contraption', contraptionId,
16342
+ ...(missing.length ? { warning: 'no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does' } : {}), };`;
16343
+ }
16344
+ case 'remove_contraption':
16345
+ return `${refuse('removeContraption')}
16346
+ const removed = app.removeContraption(${jsLiteral(input.contraptionId)});
16347
+ return removed ? { success: true, action: 'remove_contraption', contraptionId: ${jsLiteral(input.contraptionId)} }
16348
+ : { success: false, action: 'remove_contraption', error: 'no rule has the id ' + ${jsLiteral(input.contraptionId)} };`;
16349
+ case 'clear_contraptions':
16350
+ return `${refuse('clearContraptions')}
16351
+ app.clearContraptions();
16352
+ return { success: true, action: 'clear_contraptions' };`;
16353
+ case 'clear_variables':
16354
+ return `${refuse('clearVariables')}
16355
+ return { success: true, action: 'clear_variables', scope: ${scope}, removed: app.clearVariables(${scope}) };`;
16356
+ case 'list_contraptions':
16357
+ return `${refuse('listContraptions')}
16358
+ return { success: true, action: 'list_contraptions', contraptions: app.listContraptions() };`;
16359
+ case 'set_variable':
16360
+ return `${refuse('setVariable')}
16361
+ app.setVariable(${key}, ${jsLiteral(input.value)}, ${scope});
16362
+ return { success: true, action: 'set_variable', key: ${key}, value: app.getVariable(${key}, null, ${scope}) };`;
16363
+ case 'get_variable':
16364
+ return `${refuse('getVariable')}
16365
+ return { success: true, action: 'get_variable', key: ${key}, value: app.getVariable(${key}, ${jsLiteral(input.fallback ?? null)}, ${scope}) };`;
16366
+ case 'increment_variable':
16367
+ return `${refuse('incrementVariable')}
16368
+ return { success: true, action: 'increment_variable', key: ${key}, value: app.incrementVariable(${key}, ${input.by ?? 1}, ${scope}) };`;
16369
+ default: {
16370
+ const itemId = jsLiteral(input.itemId);
16371
+ return `${refuse('bindTextToState')}
16372
+ const item = app.getItemById(${itemId});
16373
+ if (!item) return { success: false, action: 'bind_text', error: 'Item not found: ' + ${itemId} };
16374
+ if (item.content === undefined) return { success: false, action: 'bind_text', error: ${itemId} + ' is not a text item, so it has no text to bind' };
16375
+ app.bindTextToState(${itemId}, ${jsLiteral(input.template)}, ${scope});
16376
+ return { success: true, action: 'bind_text', itemId: ${itemId}, text: item.content };`;
16377
+ }
16378
+ }
16379
+ };
16380
+ return `
16381
+ // Contraption engine: ${input.action}
16382
+ (function() {
16383
+ ${body()}
16384
+ })();`.trim();
16385
+ }
16111
16386
  // Export singleton instance
16112
16387
  export const codeGenerator = new PinePaperCodeGenerator();
16113
16388
  /**