@pinepaper.studio/mcp-server 1.6.31 → 1.6.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +79 -1
  2. package/SECURITY.md +1 -1
  3. package/dist/agent/types.d.ts +10 -10
  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 +2 -0
  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 +108 -45
  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 +5 -5
  41. package/dist/tools/engine-lists.js +5 -5
  42. package/dist/tools/engine-methods.d.ts.map +1 -1
  43. package/dist/tools/engine-methods.js +3 -0
  44. package/dist/tools/engine-methods.js.map +1 -1
  45. package/dist/tools/engine-surface.d.ts +1 -1
  46. package/dist/tools/engine-surface.d.ts.map +1 -1
  47. package/dist/tools/engine-surface.js +20 -4
  48. package/dist/tools/engine-surface.js.map +1 -1
  49. package/dist/tools/handlers/generate.d.ts +2 -2
  50. package/dist/tools/handlers.d.ts.map +1 -1
  51. package/dist/tools/handlers.js +38 -7
  52. package/dist/tools/handlers.js.map +1 -1
  53. package/dist/tools/lane.d.ts +1 -1
  54. package/dist/tools/lane.d.ts.map +1 -1
  55. package/dist/tools/lane.js +1 -1
  56. package/dist/tools/lane.js.map +1 -1
  57. package/dist/tools/minimal-descriptions.js +1 -1
  58. package/dist/tools/minimal-descriptions.js.map +1 -1
  59. package/dist/tools/shape-params.d.ts +2 -2
  60. package/dist/tools/shape-params.d.ts.map +1 -1
  61. package/dist/tools/shape-params.js +8 -2
  62. package/dist/tools/shape-params.js.map +1 -1
  63. package/dist/tools/stick-vocabulary.d.ts +4 -4
  64. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  65. package/dist/tools/stick-vocabulary.js +15 -4
  66. package/dist/tools/stick-vocabulary.js.map +1 -1
  67. package/dist/types/code-generator.d.ts +8 -0
  68. package/dist/types/code-generator.d.ts.map +1 -1
  69. package/dist/types/code-generator.js +241 -45
  70. package/dist/types/code-generator.js.map +1 -1
  71. package/dist/types/schemas.d.ts +246 -105
  72. package/dist/types/schemas.d.ts.map +1 -1
  73. package/dist/types/schemas.js +69 -5
  74. package/dist/types/schemas.js.map +1 -1
  75. package/dist/utils/env.d.ts +13 -41
  76. package/dist/utils/env.d.ts.map +1 -1
  77. package/dist/utils/env.js +25 -43
  78. package/dist/utils/env.js.map +1 -1
  79. package/dist/utils/exit-when-client-gone.d.ts +10 -0
  80. package/dist/utils/exit-when-client-gone.d.ts.map +1 -0
  81. package/dist/utils/exit-when-client-gone.js +21 -0
  82. package/dist/utils/exit-when-client-gone.js.map +1 -0
  83. package/dist/vendor/design/design-style.d.ts +2 -2
  84. package/dist/vendor/design/design-style.d.ts.map +1 -1
  85. package/dist/vendor/design/design-style.js +5 -2
  86. package/dist/vendor/design/design-style.js.map +1 -1
  87. package/manifest.json +1 -1
  88. package/package.json +1 -1
@@ -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)};
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 { ${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);
@@ -1470,7 +1568,7 @@ String(jsLiteral(targetId)) +
1470
1568
  ", " +
1471
1569
  String(jsLiteral(relationType)) +
1472
1570
  ", " +
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();
1571
+ 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)}` : ''; })()) + String(customOptionWarningJs(relationType, params, 'warning')) + " };\n})();\n").trim();
1474
1572
  }
1475
1573
  /**
1476
1574
  * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
@@ -1802,7 +1900,7 @@ const formatted = relations.map(r => ({
1802
1900
  // this tool even though it was wired correctly on the canvas.
1803
1901
  targetId: r.target ?? r.targetId ?? null,
1804
1902
  relationType: r.relation || r.type,
1805
- params: r.params || {}
1903
+ params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
1806
1904
  }));
1807
1905
 
1808
1906
  ({ relations: formatted });
@@ -1822,7 +1920,7 @@ const relations = results.map(r => ({
1822
1920
  // the filter string labelled every edge 'unknown' on an unfiltered query —
1823
1921
  // which is exactly the query you make when exploring an unfamiliar graph.
1824
1922
  relationType: r.relation || ${relationType ? jsLiteral(relationType) : "'unknown'"},
1825
- params: r.params || {}
1923
+ params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
1826
1924
  }));
1827
1925
 
1828
1926
  ({ relations });
@@ -2246,6 +2344,29 @@ function withRadiusAxes(props) {
2246
2344
  out.height = radiusY * 2;
2247
2345
  return out;
2248
2346
  }
2347
+ /**
2348
+ * One get_items entry. What create applied is read back here, so an agent can check it: the name a patch addresses
2349
+ * the item by, and scale, rotation and opacity when they are not the default (CROWSCALE30: a create-time scale
2350
+ * rendered exactly and get_items did not show it; PATCHIMG29: an import's name was not shown either). Defaults are
2351
+ * left out, so a plain item's entry stays as small as before.
2352
+ */
2353
+ 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" +
2354
+ " = { 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}"
2355
+
2356
+
2357
+
2358
+
2359
+
2360
+
2361
+
2362
+
2363
+
2364
+
2365
+
2366
+
2367
+
2368
+
2369
+ );
2249
2370
  function generateGetItemsCode(filter) {
2250
2371
  // A filter OBJECT is not the same as a filter. `{}` is already handled, but
2251
2372
  // so is `{ type: undefined }` and anything whose keys this function does not
@@ -2270,13 +2391,7 @@ function generateGetItemsCode(filter) {
2270
2391
  return `
2271
2392
  // Get all items
2272
2393
  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
- }));
2394
+ const items = entries.map(${GET_ITEMS_ENTRY_JS});
2280
2395
 
2281
2396
  ({ items, count: items.length });
2282
2397
  `.trim();
@@ -2285,13 +2400,7 @@ const items = entries.map(entry => ({
2285
2400
  // Get items with filter
2286
2401
  const entries = app.itemRegistry.getAll();
2287
2402
  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
- }));
2403
+ const items = filtered.map(${GET_ITEMS_ENTRY_JS});
2295
2404
 
2296
2405
  ({ items, count: items.length });
2297
2406
  `.trim();
@@ -2542,7 +2651,12 @@ function generateClearCanvasCode() {
2542
2651
  String(MEDIA_PURGE) +
2543
2652
  "\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
2653
  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"
2654
+ "\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"
2655
+
2656
+
2657
+
2658
+
2659
+
2546
2660
 
2547
2661
 
2548
2662
 
@@ -5181,7 +5295,7 @@ String(type) +
5181
5295
  ", sourceIds: __failed, error: " +
5182
5296
  String(op.update === true ? '__why' : `'the studio refused the ' + ${type} + ' relation for ' + __failed.join(', ') + ' — nothing was bound' + (__why ? ': ' + __why : '.')`) +
5183
5297
  " };\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");
5298
+ 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)}` : ''; })()) + String(customOptionWarningJs(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
5299
  }
5186
5300
  return ("\n" +
5187
5301
  String(stepGuard) +
@@ -5200,7 +5314,7 @@ String(relOpts) +
5200
5314
  ");\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
5315
  String(relationRefusalReasonJs(jsLiteral(op.relationType))) +
5202
5316
  ";\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");
5317
+ 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)}` : ''; })()) + String(customOptionWarningJs(op.relationType, op.relationOptions, 'warning')) + " };\n");
5204
5318
  case 'delete':
5205
5319
  const delRef = batchRef(op.itemId);
5206
5320
  return `
@@ -5429,7 +5543,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5429
5543
  }
5430
5544
  generateAgentExport(input) {
5431
5545
  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;
5546
+ 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
5547
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
5434
5548
  const videoEncodeOpts = {};
5435
5549
  // A RANGE of the timeline (FxTool #250): frames for t in [startTime, startTime + duration), each exactly the frame
@@ -5451,8 +5565,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5451
5565
  videoEncodeOpts.minBitrate = minBitrate;
5452
5566
  if (bitrateMode !== undefined)
5453
5567
  videoEncodeOpts.bitrateMode = bitrateMode;
5454
- // Byte-identical repeats (FxTool 3b335567): the container timestamps are
5455
- // the only bytes that varied, and this pins them.
5568
+ // B45 (FxTool #288): 'aac' makes the studio refuse an MP4 whose soundtrack would be Opus (code
5569
+ // audio_codec_unavailable) rather than write a file WhatsApp rejects. 'any' is the engine's default, so not sent.
5570
+ if (audioCodec === 'aac')
5571
+ videoEncodeOpts.audioCodec = 'aac';
5572
+ // Determinism (B31, the contract signed off in FxTool #290): identical encoder
5573
+ // input and a container identical apart from the encoded samples; the file
5574
+ // itself repeats only where the studio reports bitExact: true.
5456
5575
  if (deterministic)
5457
5576
  videoEncodeOpts.deterministic = true;
5458
5577
  // Motion blur (FxTool #55): the engine forwards it on every mp4/webm path
@@ -6086,9 +6205,10 @@ String(videoDuration) +
6086
6205
  ".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
6206
  "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
6207
  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"
6208
+ "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 " +
6209
+ "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" +
6210
+ "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"
6211
+
6092
6212
 
6093
6213
 
6094
6214
 
@@ -7227,6 +7347,7 @@ String(jsLiteral(format)) +
7227
7347
 
7228
7348
 
7229
7349
 
7350
+
7230
7351
 
7231
7352
 
7232
7353
  ).trim();
@@ -8840,7 +8961,7 @@ String(validated.priority || 0) +
8840
8961
  "\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
8962
  String(jsLiteral(validated.name)) +
8842
8963
  "] = " +
8843
- String(jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction })) +
8964
+ 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
8965
  ";\n return { success: true, relationName: " +
8845
8966
  String(jsLiteral(validated.name)) +
8846
8967
  ", message: 'Custom relation registered successfully' };\n } catch (error) {\n return { success: false, error: error.message };\n }\n})();\n"
@@ -12761,16 +12882,20 @@ String(diagJson) +
12761
12882
  String(optsJson) +
12762
12883
  ");\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
12884
  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 (" +
12885
+ ") 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: " +
12886
+ String(RELATION_PUBLIC_PARAMS_JS) +
12887
+ "(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" +
12888
+ "wn */ }\n const __allCustom = (typeof window !== 'undefined' && window.__ppCustomRelations) ? Object.values(window.__ppCustomRelations) : [];\n const __strip = (d) => { if (" +
12766
12889
  String(input.withSource === true ? 'true' : 'false') +
12767
12890
  ") 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 (!" +
12891
+ "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" +
12892
+ "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" +
12893
+ "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" +
12894
+ "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(" +
12895
+ String(input.withSource === true ? "{ name: d.name, change: { tool: 'pinepaper_register_custom_relation', args } }" : "args") +
12896
+ ");\n });\n } catch (e) { /* the read-back stands without the edit ops */ }\n if (!" +
12772
12897
  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 (" +
12898
+ " && __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
12899
  String(input.only === 'edit' ? 'true' : 'false') +
12775
12900
  ") {\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
12901
  ", 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 +12961,7 @@ String(input.only === 'edit' ? 'true' : 'false') +
12836
12961
 
12837
12962
 
12838
12963
 
12964
+
12839
12965
 
12840
12966
 
12841
12967
  ).trim();
@@ -13385,7 +13511,7 @@ String(id) +
13385
13511
  String(id) +
13386
13512
  ", beats: " +
13387
13513
  String(input.beats.length) +
13388
- ", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };\n" +
13514
+ ", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final, ...(__r.defaultsUsed ? { defaultsUsed: __r.defaultsUsed } : {}) };\n" +
13389
13515
  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
13516
  String(S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})) +
13391
13517
  ");\n const __gain = " +
@@ -16094,6 +16220,8 @@ String(op) +
16094
16220
  return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
16095
16221
  })();`.trim();
16096
16222
  }
16223
+ if (input.action !== 'pulse')
16224
+ return generateContraptionCode(input);
16097
16225
  // pulse — a runtime fire, not a scene edit (no history save)
16098
16226
  const eventIdJson = JSON.stringify(input.eventId);
16099
16227
  const payloadJson = JSON.stringify(input.payload ?? null);
@@ -16108,6 +16236,74 @@ String(op) +
16108
16236
  })();`.trim();
16109
16237
  }
16110
16238
  }
16239
+ /**
16240
+ * Rules, variables and bound text (the studio's contraption engine). Each action asks for the method it calls, so a
16241
+ * studio without it refuses by name instead of a TypeError. The engine's animateItem, playSound and custom actions are
16242
+ * not offered: animateItem goes through app.modify, which acts on the selection and not on its target; playSound does
16243
+ * not call app.playSound the way that method is written; custom needs a function, which JSON cannot carry. All three
16244
+ * answer ok regardless, so a rule using them would report success for nothing.
16245
+ */
16246
+ function generateContraptionCode(input) {
16247
+ const refuse = (method) => `if (typeof app.${method} !== 'function') {
16248
+ return { success: false, action: ${jsLiteral(input.action)}, error: 'this studio has no contraption engine (app.${method}): update PinePaper Studio' };
16249
+ }`;
16250
+ const scope = jsLiteral(input.scope ?? 'default');
16251
+ const key = jsLiteral(input.key ?? '');
16252
+ const body = () => {
16253
+ switch (input.action) {
16254
+ case 'add_contraption': {
16255
+ const targets = [...new Set((input.actions ?? []).flatMap((a) => (a.type === 'modifyItem' ? [a.targetId] : [])))];
16256
+ const config = {
16257
+ ...(input.contraptionId ? { id: input.contraptionId } : {}),
16258
+ sourceEvent: input.sourceEvent,
16259
+ actions: input.actions,
16260
+ ...(input.condition ? { condition: input.condition } : {}),
16261
+ ...(input.thenTrigger ? { thenTrigger: input.thenTrigger } : {}),
16262
+ ...(input.elseTrigger ? { elseTrigger: input.elseTrigger } : {}),
16263
+ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
16264
+ };
16265
+ return `${refuse('addContraption')}
16266
+ const contraptionId = app.addContraption(${jsLiteral(config)});
16267
+ const missing = ${jsLiteral(targets)}.filter((id) => !app.getItemById(id));
16268
+ return { success: true, action: 'add_contraption', contraptionId,
16269
+ ...(missing.length ? { warning: 'no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does' } : {}),
16270
+ ${targets.length ? "note: 'changes a rule makes to an item stay: nothing rewinds them, so another export starts from the changed scene'" : ''} };`;
16271
+ }
16272
+ case 'remove_contraption':
16273
+ return `${refuse('removeContraption')}
16274
+ const removed = app.removeContraption(${jsLiteral(input.contraptionId)});
16275
+ return removed ? { success: true, action: 'remove_contraption', contraptionId: ${jsLiteral(input.contraptionId)} }
16276
+ : { success: false, action: 'remove_contraption', error: 'no rule has the id ' + ${jsLiteral(input.contraptionId)} };`;
16277
+ case 'list_contraptions':
16278
+ return `${refuse('listContraptions')}
16279
+ return { success: true, action: 'list_contraptions', contraptions: app.listContraptions() };`;
16280
+ case 'set_variable':
16281
+ return `${refuse('setVariable')}
16282
+ app.setVariable(${key}, ${jsLiteral(input.value)}, ${scope});
16283
+ return { success: true, action: 'set_variable', key: ${key}, value: app.getVariable(${key}, null, ${scope}) };`;
16284
+ case 'get_variable':
16285
+ return `${refuse('getVariable')}
16286
+ return { success: true, action: 'get_variable', key: ${key}, value: app.getVariable(${key}, ${jsLiteral(input.fallback ?? null)}, ${scope}) };`;
16287
+ case 'increment_variable':
16288
+ return `${refuse('incrementVariable')}
16289
+ return { success: true, action: 'increment_variable', key: ${key}, value: app.incrementVariable(${key}, ${input.by ?? 1}, ${scope}) };`;
16290
+ default: {
16291
+ const itemId = jsLiteral(input.itemId);
16292
+ return `${refuse('bindTextToState')}
16293
+ const item = app.getItemById(${itemId});
16294
+ if (!item) return { success: false, action: 'bind_text', error: 'Item not found: ' + ${itemId} };
16295
+ if (item.content === undefined) return { success: false, action: 'bind_text', error: ${itemId} + ' is not a text item, so it has no text to bind' };
16296
+ app.bindTextToState(${itemId}, ${jsLiteral(input.template)}, ${scope});
16297
+ return { success: true, action: 'bind_text', itemId: ${itemId}, text: item.content };`;
16298
+ }
16299
+ }
16300
+ };
16301
+ return `
16302
+ // Contraption engine: ${input.action}
16303
+ (function() {
16304
+ ${body()}
16305
+ })();`.trim();
16306
+ }
16111
16307
  // Export singleton instance
16112
16308
  export const codeGenerator = new PinePaperCodeGenerator();
16113
16309
  /**