@pinepaper.studio/mcp-server 1.6.20 → 1.6.21

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 (91) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +79 -5
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/build-info.json +3 -3
  11. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  12. package/dist/ontology/upstream-vocabulary.js +3 -3
  13. package/dist/ontology/upstream-vocabulary.js.map +1 -1
  14. package/dist/ontology/vocabulary.js +1 -1
  15. package/dist/ontology/vocabulary.js.map +1 -1
  16. package/dist/tools/choreograph.d.ts.map +1 -1
  17. package/dist/tools/choreograph.js +10 -0
  18. package/dist/tools/choreograph.js.map +1 -1
  19. package/dist/tools/craft-tools.d.ts +55 -0
  20. package/dist/tools/craft-tools.d.ts.map +1 -0
  21. package/dist/tools/craft-tools.js +139 -0
  22. package/dist/tools/craft-tools.js.map +1 -0
  23. package/dist/tools/definitions.d.ts.map +1 -1
  24. package/dist/tools/definitions.js +117 -7
  25. package/dist/tools/definitions.js.map +1 -1
  26. package/dist/tools/effect-class.d.ts.map +1 -1
  27. package/dist/tools/effect-class.js +6 -0
  28. package/dist/tools/effect-class.js.map +1 -1
  29. package/dist/tools/engine-lists.d.ts +2 -2
  30. package/dist/tools/engine-lists.js +2 -2
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +4 -0
  33. package/dist/tools/engine-methods.js.map +1 -1
  34. package/dist/tools/engine-surface.d.ts +1 -1
  35. package/dist/tools/engine-surface.d.ts.map +1 -1
  36. package/dist/tools/engine-surface.js +38 -5
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/generate.d.ts +28 -28
  39. package/dist/tools/handlers.d.ts.map +1 -1
  40. package/dist/tools/handlers.js +125 -1
  41. package/dist/tools/handlers.js.map +1 -1
  42. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  43. package/dist/tools/minimal-descriptions.js +7 -0
  44. package/dist/tools/minimal-descriptions.js.map +1 -1
  45. package/dist/tools/shape-params.d.ts +2 -2
  46. package/dist/tools/shape-params.d.ts.map +1 -1
  47. package/dist/tools/shape-params.js +3 -2
  48. package/dist/tools/shape-params.js.map +1 -1
  49. package/dist/tools/stick-vocabulary.d.ts +1 -1
  50. package/dist/tools/stick-vocabulary.js +1 -1
  51. package/dist/tools/toolkits.d.ts.map +1 -1
  52. package/dist/tools/toolkits.js +9 -0
  53. package/dist/tools/toolkits.js.map +1 -1
  54. package/dist/types/code-generator.d.ts +10 -1
  55. package/dist/types/code-generator.d.ts.map +1 -1
  56. package/dist/types/code-generator.js +605 -107
  57. package/dist/types/code-generator.js.map +1 -1
  58. package/dist/types/schemas.d.ts +1745 -582
  59. package/dist/types/schemas.d.ts.map +1 -1
  60. package/dist/types/schemas.js +144 -4
  61. package/dist/types/schemas.js.map +1 -1
  62. package/dist/vendor/design/design-style.d.ts +2 -2
  63. package/dist/vendor/design/design-style.d.ts.map +1 -1
  64. package/dist/vendor/design/design-style.js +7 -2
  65. package/dist/vendor/design/design-style.js.map +1 -1
  66. package/dist/vendor/design/subdivision-tool-schema.d.ts +16 -0
  67. package/dist/vendor/design/subdivision-tool-schema.d.ts.map +1 -0
  68. package/dist/vendor/design/subdivision-tool-schema.js +104 -0
  69. package/dist/vendor/design/subdivision-tool-schema.js.map +1 -0
  70. package/dist/vendor/design/textile-tool-schema.d.ts +14 -0
  71. package/dist/vendor/design/textile-tool-schema.d.ts.map +1 -0
  72. package/dist/vendor/design/textile-tool-schema.js +195 -0
  73. package/dist/vendor/design/textile-tool-schema.js.map +1 -0
  74. package/dist/vendor/design/ui-kit-runtime.d.ts +49 -0
  75. package/dist/vendor/design/ui-kit-runtime.d.ts.map +1 -0
  76. package/dist/vendor/design/ui-kit-runtime.js +761 -0
  77. package/dist/vendor/design/ui-kit-runtime.js.map +1 -0
  78. package/dist/vendor/design/ui-kit-tool-schema.d.ts +280 -0
  79. package/dist/vendor/design/ui-kit-tool-schema.d.ts.map +1 -0
  80. package/dist/vendor/design/ui-kit-tool-schema.js +209 -0
  81. package/dist/vendor/design/ui-kit-tool-schema.js.map +1 -0
  82. package/manifest.json +29 -1
  83. package/package.json +2 -2
  84. package/dist/assets/repositories/openclipart.d.ts +0 -41
  85. package/dist/assets/repositories/openclipart.d.ts.map +0 -1
  86. package/dist/assets/repositories/openclipart.js +0 -139
  87. package/dist/assets/repositories/openclipart.js.map +0 -1
  88. package/dist/assets/repositories/svgrepo.d.ts +0 -61
  89. package/dist/assets/repositories/svgrepo.d.ts.map +0 -1
  90. package/dist/assets/repositories/svgrepo.js +0 -276
  91. package/dist/assets/repositories/svgrepo.js.map +0 -1
@@ -74,9 +74,11 @@ function normalizeGradientSpec(v, key) {
74
74
  */
75
75
  function generateGradientCode(gradient, itemVarName, position, propertyName = 'fillColor') {
76
76
  const { type, stops, origin, destination } = gradient;
77
- // Calculate default origin/destination based on item position
78
- const defaultOrigin = origin || [position.x - 50, position.y - 50];
79
- const defaultDestination = destination || [position.x + 50, position.y + 50];
77
+ // Calculate default origin/destination based on item position.
78
+ // For radial gradients, origin defaults to the item center (position.x, position.y),
79
+ // not an offset (-50, -50), and destination to the radius edge (B88).
80
+ const defaultOrigin = origin || (type === 'radial' ? [position.x, position.y] : [position.x - 50, position.y - 50]);
81
+ const defaultDestination = destination || (type === 'radial' ? [position.x + 50, position.y] : [position.x + 50, position.y + 50]);
80
82
  const stopsCode = stops.map(s => `['${s.color}', ${s.offset}]`).join(', ');
81
83
  return `
82
84
  ${itemVarName}.${propertyName} = {
@@ -130,6 +132,18 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
130
132
  properties = { ...properties, [key]: g.gradient };
131
133
  }
132
134
  }
135
+ // Aliases for text items: text -> content (B66), textAlign / align -> justification (B89)
136
+ if (properties.content === undefined && properties.text !== undefined) {
137
+ properties = { ...properties, content: properties.text };
138
+ }
139
+ if (properties.justification === undefined) {
140
+ if (properties.textAlign !== undefined) {
141
+ properties = { ...properties, justification: properties.textAlign };
142
+ }
143
+ else if (properties.align !== undefined) {
144
+ properties = { ...properties, justification: properties.align };
145
+ }
146
+ }
133
147
  // Extract special properties that need separate handling
134
148
  const { color, fillColor, strokeColor, shadowColor, shadowBlur, shadowOffset, blendMode, opacity, contentType, contentFormat, countdownTarget, countdownEndText, ...baseProperties } = properties;
135
149
  // A PATH KEEPS ITS OWN COORDINATES WHEN NO POSITION WAS ASKED FOR (round 9
@@ -194,18 +208,15 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
194
208
  if (strokeColor && !isGradient(strokeColor)) {
195
209
  params.strokeColor = strokeColor;
196
210
  }
197
- // RENDER-TIME SURFACES DEGRADE, THEY DO NOT FAIL.
198
- //
199
- // `shader` and `field` are drawn per pixel by the cloud rasterizer from the
200
- // scene document; the browser engine has no such item type and answers
201
- // `create('shader')` with "unknown type — nothing was created". Emitting that
202
- // call would hand every local user a silent hole where their backdrop was.
211
+ // RENDER-TIME SURFACES ARE A TAGGED PLATE.
203
212
  //
204
- // So locally they become a plate: a rectangle carrying the same id, position,
205
- // size and colour, which keeps the composition readable and the item
206
- // addressable by every later call. The scene document still says `shader`, so
207
- // a cloud render draws the real surface. The divergence is declared here
208
- // rather than discovered in a frame.
213
+ // `shader` and `field` are drawn per pixel from the scene document. What is
214
+ // created is a rectangle carrying the id, position, size and colour, tagged
215
+ // data.renderAs + data.renderParams. The studio adopts that plate and draws
216
+ // the surface over it in the live view, stills and every export (FxTool D19);
217
+ // the cloud rasterizer draws it from the same tags. A studio too old to adopt
218
+ // it still shows a readable, addressable plate rather than a hole, which is
219
+ // why this does not call create('shader').
209
220
  if (itemType === 'shader' || itemType === 'field') {
210
221
  const plate = {
211
222
  x: position.x,
@@ -219,8 +230,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
219
230
  if (params.opacity != null)
220
231
  plate.opacity = params.opacity;
221
232
  return `
222
- // ${itemType} — a render-time surface. Drawn per pixel by the cloud renderer;
223
- // stood in for locally by a flat plate so the layout still reads.
233
+ // ${itemType} — a render-time surface: a plate tagged with what to draw over it.
224
234
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
225
235
  if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
226
236
  // The snippet's value is its last statement's, which was the assignment above:
@@ -229,10 +239,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
229
239
  // \`}\` as a statement end, so a bare ternary of object literals is not wrapped
230
240
  // and its value is lost.
231
241
  ((item && item.data)
232
- ? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} },
233
- localStandIn: true,
234
- note: 'drawn here as a flat plate; the ${itemType} itself appears only in a cloud render. A local export shows the plate.' }
235
- : { success: false, error: 'the stand-in plate for this ${itemType} was not created.' });`;
242
+ ? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} } }
243
+ : { success: false, error: 'the plate for this ${itemType} was not created.' });`;
236
244
  }
237
245
  // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
238
246
  //
@@ -334,9 +342,14 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
334
342
  if (textFit)
335
343
  code += `\nlet __textFit = null;${emitTextFit('item', textFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null))}`;
336
344
  // After opacity, so the "on" level of the lifetime is the item's own.
337
- const hasLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
345
+ // A numeric `lifetime` is the studio's alias of ttl on create (FxTool B125;
346
+ // emitters, where it means each particle's life, are not made here).
347
+ // Resolved here too: with bornAt beside it, passing ttl undefined would reset
348
+ // the item to "until the end" after create() had set the alias.
349
+ const createTtl = properties.ttl ?? (typeof properties.lifetime === 'number' ? properties.lifetime : undefined);
350
+ const hasLifetime = properties.bornAt !== undefined || createTtl !== undefined;
338
351
  if (hasLifetime) {
339
- code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt, properties.ttl)}`;
352
+ code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt, createTtl)}`;
340
353
  }
341
354
  // Ensure item is visible above backgrounds/generators
342
355
  code += `\nif (item.bringToFront) item.bringToFront();`;
@@ -403,9 +416,8 @@ const CREATE_KNOWN_KEYS = new Set([
403
416
  // withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
404
417
  // setDynamicContent for text.
405
418
  'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
406
- // Lifetimes. The editor's create() does not read them, but they are
407
- // documented on ANY item and the scene renderer schedules by them — calling
408
- // them "no effect" would steer a caller off the only way to cut between shots.
419
+ // Lifetimes, on ANY item: this generator applies them through app.setLifetime
420
+ // (emitLifetime), whatever create() itself reads.
409
421
  'bornAt', 'ttl',
410
422
  ]);
411
423
  /**
@@ -626,7 +638,25 @@ if (typeof app.fitText === 'function') {
626
638
  *
627
639
  * lineHeight is the CSS habit: <= 4 is a multiple of fontSize, above that px.
628
640
  */
629
- const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight'];
641
+ /**
642
+ * THE FRAME A MOTION IS AUTHORED AGAINST.
643
+ *
644
+ * app.exportFrameRect() is the export frame. On a bounded canvas that is the
645
+ * artboard. On an UNBOUNDED canvas (prod's default) it is the region the items
646
+ * currently occupy, padded: with one actor on the canvas it is the actor
647
+ * itself, so choreograph called every move "off screen" and the morph camera
648
+ * framed against the hero alone. An unbounded canvas has no fixed frame, so
649
+ * motion is checked against its nominal artboard instead, flagged unbounded so
650
+ * the caller can say so.
651
+ */
652
+ function authoringFrameJs(name) {
653
+ return `const ${name} = (function() {
654
+ const cs = app.getCanvasSize && app.getCanvasSize();
655
+ if (cs && cs.unbounded && cs.width > 0 && cs.height > 0) return { x: 0, y: 0, width: cs.width, height: cs.height, unbounded: true };
656
+ return (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || cs || { width: 1920, height: 1080 };
657
+ })();`;
658
+ }
659
+ const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'textAlign', 'align', 'text'];
630
660
  function emitTextStyle(itemExpr, props) {
631
661
  const weight = props.fontWeight;
632
662
  const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
@@ -711,10 +741,11 @@ function resolveChartFields(chartType, data, options) {
711
741
  * same way for the same reason. A 1-microsecond tolerance makes a hand-off at
712
742
  * an exact frame time show exactly one item.
713
743
  *
714
- * Runtime-only: a scene saved and reopened keeps data.bornAt / ttl (the cloud
715
- * renderer reads those) but not the callback, until the studio reads the
716
- * fields itself — the result says so. Lifetimes applied the old way are
717
- * migrated: their tagged opacity keys are removed.
744
+ * The callback is the FALLBACK, for a studio without app.setLifetime: it is
745
+ * not saved, so a reopened scene keeps data.bornAt / ttl (the cloud renderer
746
+ * reads those) but not the hiding. app.setLifetime persists; the result's
747
+ * lifetime field says which ran. Lifetimes applied the old way are migrated:
748
+ * their tagged opacity keys are removed.
718
749
  */
719
750
  function emitLifetime(itemExpr, bornAt, ttl) {
720
751
  const b = Number(bornAt);
@@ -793,6 +824,27 @@ function emitLifetime(itemExpr, bornAt, ttl) {
793
824
  */
794
825
  function generateModifyItemCode(itemId, properties, dataFlags) {
795
826
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
827
+ // Aliases for text items: text -> content (B66), textAlign / align -> justification (B89).
828
+ // A key consumed here was applied, so the unread-key report below must not
829
+ // call it "no effect".
830
+ const aliased = new Set();
831
+ if (restProperties.justification === undefined) {
832
+ if (restProperties.textAlign !== undefined) {
833
+ restProperties.justification = restProperties.textAlign;
834
+ delete restProperties.textAlign;
835
+ aliased.add('textAlign');
836
+ }
837
+ else if (restProperties.align !== undefined) {
838
+ restProperties.justification = restProperties.align;
839
+ delete restProperties.align;
840
+ aliased.add('align');
841
+ }
842
+ }
843
+ if (restProperties.content === undefined && restProperties.text !== undefined) {
844
+ restProperties.content = restProperties.text;
845
+ delete restProperties.text;
846
+ aliased.add('text');
847
+ }
796
848
  // A new fit box is applied here (emitTextFit, with the hold carried over),
797
849
  // not by modifyItem: the engine's modify fits it with no hold, overwriting
798
850
  // the one this reads back. fit: null still goes through — that is the
@@ -928,7 +980,7 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
928
980
  // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
929
981
  // raster answered success and left it 16 px; the engine reads `scale`. Same
930
982
  // report create_item gives, against the engine's modify path (generated).
931
- const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k));
983
+ const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
932
984
  const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
933
985
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
934
986
  code += `
@@ -1225,10 +1277,22 @@ app.animate(item, {
1225
1277
  */
1226
1278
  function normalizeKeyframePositions(keyframes) {
1227
1279
  return (keyframes || []).map((kf) => {
1228
- const props = kf?.properties;
1229
- const p = props?.position;
1230
- if (p && !Array.isArray(p) && typeof p === 'object' && 'x' in p && 'y' in p) {
1231
- return { ...kf, properties: { ...props, position: [p.x, p.y] } };
1280
+ const rawProps = kf?.properties;
1281
+ const props = rawProps && typeof rawProps === 'object' ? rawProps : {};
1282
+ const p = props.position ?? kf?.position;
1283
+ if (p) {
1284
+ if (Array.isArray(p) && p.length >= 2) {
1285
+ return { ...kf, properties: { ...props, position: [p[0], p[1]] } };
1286
+ }
1287
+ if (typeof p === 'object' && 'x' in p && 'y' in p) {
1288
+ return { ...kf, properties: { ...props, position: [p.x, p.y] } };
1289
+ }
1290
+ }
1291
+ if (kf?.x !== undefined && kf?.y !== undefined && props.position === undefined && props.x === undefined && props.y === undefined) {
1292
+ return { ...kf, properties: { ...props, position: [kf.x, kf.y] } };
1293
+ }
1294
+ if (rawProps === undefined && Object.keys(props).length > 0) {
1295
+ return { ...kf, properties: props };
1232
1296
  }
1233
1297
  return kf;
1234
1298
  });
@@ -1251,9 +1315,14 @@ function normalizeKeyframePositions(keyframes) {
1251
1315
  * "item_7 is not on the canvas" is actionable, silence is not.
1252
1316
  */
1253
1317
  function requireItem(itemId, what) {
1318
+ const isRef = typeof itemId === 'string' && itemId.startsWith('$');
1319
+ const targetExpr = isRef
1320
+ ? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || '${itemId}')`
1321
+ : `'${itemId}'`;
1254
1322
  return `
1255
- const _target = (app.getItemById && app.getItemById(${JSON.stringify(itemId)}))
1256
- || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}));
1323
+ const _targetId = ${targetExpr};
1324
+ const _target = (app.getItemById && app.getItemById(_targetId))
1325
+ || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(_targetId));
1257
1326
  if (!_target) {
1258
1327
  return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
1259
1328
  }`.trim();
@@ -1287,7 +1356,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1287
1356
  // is the feature check: a key it names for audio passes through, anything
1288
1357
  // else on an audio item is still refused (prod, until that build ships).
1289
1358
  const audioLevelGuard = levelKeys.length === 0 ? '' : `
1290
- const __ae = app.itemRegistry && app.itemRegistry.get('${itemId}');
1359
+ const __ae = app.itemRegistry && app.itemRegistry.get(_targetId);
1291
1360
  let __audioKeyed = [];
1292
1361
  try {
1293
1362
  const __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null;
@@ -1295,7 +1364,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1295
1364
  } catch (_) { /* treat as unsupported */ }
1296
1365
  const __unsupported = ${JSON.stringify(levelKeys)}.filter(function(k) { return __audioKeyed.indexOf(k) === -1; });
1297
1366
  if (__ae && __ae.type === 'audio' && __unsupported.length) {
1298
- return { success: false, itemId: '${itemId}', error: __unsupported.join(', ') + ' cannot be keyframed on an audio item in this studio: it mixes each audio clip at one fixed level, so a fade would be accepted and not heard. Nothing was added. '
1367
+ return { success: false, itemId: _targetId, error: __unsupported.join(', ') + ' cannot be keyframed on an audio item in this studio: it mixes each audio clip at one fixed level, so a fade would be accepted and not heard. Nothing was added. '
1299
1368
  + (__audioKeyed.length ? 'Keyable on audio here: ' + __audioKeyed.join(', ') + '. ' : '')
1300
1369
  + 'Set a static level with pinepaper_modify_item {audioGain: 0..1}.' };
1301
1370
  }`;
@@ -1328,6 +1397,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1328
1397
  const easingNote = first?.easing && first.easing !== 'linear'
1329
1398
  ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
1330
1399
  : '';
1400
+ const unsupportedKeys = [...new Set(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))]
1401
+ .filter((k) => k === 'letterSpacing');
1402
+ const letterSpacingWarning = unsupportedKeys.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(unsupportedKeys)}, warning: ${JSON.stringify(`${unsupportedKeys.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
1403
+ const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
1331
1404
  return `
1332
1405
  // Apply keyframe animation to ${itemId}
1333
1406
  (function() {
@@ -1337,7 +1410,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1337
1410
  // merge support — an older engine takes the option and replaces anyway.
1338
1411
  const __ki = _target && (_target.item || _target);
1339
1412
  const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1340
- app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
1413
+ app.addAnimation(${addAnimTarget}, ${keyframesJson}, ${JSON.stringify(opts)});
1341
1414
  const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1342
1415
  const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
1343
1416
  // Property gaps across the merged track (the same check a single call makes
@@ -1350,7 +1423,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1350
1423
  ? { appended: true, previousKeys: __prev.length, keys: __now.length }
1351
1424
  : { appended: false, replaced: true, previousKeys: __prev.length, warning: 'append was asked for, but this studio replaced the existing track (' + __prev.length + ' key(s)) instead of adding to it: it has no merge mode. Put every key in one keyframe_animate call.' })`
1352
1425
  : `{ replaced: true, previousKeys: __prev.length, note: 'the item already had a keyframe track (' + __prev.length + ' key(s)); this call replaced it. To add keys to it instead, pass append: true.' }`};
1353
- return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote} };
1426
+ return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${letterSpacingWarning} };
1354
1427
  })();
1355
1428
  `.trim();
1356
1429
  }
@@ -1896,6 +1969,54 @@ export function isCoordinateBuilt(itemType, props) {
1896
1969
  return false;
1897
1970
  }
1898
1971
  }
1972
+ /** A keyframe's properties, with the flat spellings (x, opacity, ...) lifted in beside `properties`. */
1973
+ function extractKfPropsMap(kf) {
1974
+ const props = (kf && kf.properties && typeof kf.properties === 'object') ? { ...kf.properties } : {};
1975
+ if (kf) {
1976
+ const flat = kf;
1977
+ for (const key of ['x', 'y', 'opacity', 'scale', 'scaleX', 'scaleY', 'rotation', 'position', 'fill', 'fillColor', 'stroke', 'strokeColor', 'width', 'height', 'radius', 'lineHeight']) {
1978
+ if (flat[key] !== undefined && props[key] === undefined)
1979
+ props[key] = flat[key];
1980
+ }
1981
+ }
1982
+ return props;
1983
+ }
1984
+ /**
1985
+ * One op's track sampled at time t: held before its first key and after its last, linear in between
1986
+ * (numbers and equal-length number arrays; anything else holds until the next key is reached).
1987
+ */
1988
+ function sampleOpTrack(kfs, t) {
1989
+ if (!kfs.length)
1990
+ return {};
1991
+ if (kfs.length === 1 || t <= kfs[0].time)
1992
+ return extractKfPropsMap(kfs[0]);
1993
+ if (t >= kfs[kfs.length - 1].time)
1994
+ return extractKfPropsMap(kfs[kfs.length - 1]);
1995
+ let i = 0;
1996
+ for (let j = 0; j < kfs.length - 1; j++) {
1997
+ if (t >= kfs[j].time && t <= kfs[j + 1].time) {
1998
+ i = j;
1999
+ break;
2000
+ }
2001
+ }
2002
+ const k0 = kfs[i], k1 = kfs[i + 1];
2003
+ const p0 = extractKfPropsMap(k0), p1 = extractKfPropsMap(k1);
2004
+ const f = Math.max(0, Math.min(1, (t - k0.time) / (k1.time - k0.time || 1)));
2005
+ const out = {};
2006
+ for (const k of new Set([...Object.keys(p0), ...Object.keys(p1)])) {
2007
+ const v0 = p0[k], v1 = p1[k];
2008
+ if (typeof v0 === 'number' && typeof v1 === 'number') {
2009
+ out[k] = v0 + (v1 - v0) * f;
2010
+ }
2011
+ else if (Array.isArray(v0) && Array.isArray(v1) && v0.length === v1.length && typeof v0[0] === 'number') {
2012
+ out[k] = v0.map((n, idx) => Number(n) + (Number(v1[idx]) - Number(n)) * f);
2013
+ }
2014
+ else {
2015
+ out[k] = (v1 !== undefined && f >= 1) ? v1 : (v0 !== undefined ? v0 : v1);
2016
+ }
2017
+ }
2018
+ return out;
2019
+ }
1899
2020
  /**
1900
2021
  * app.addAnimation() REPLACES data.keyframes wholesale, so two
1901
2022
  * keyframe_animate ops targeting one item meant the first track was silently
@@ -1923,28 +2044,34 @@ export function planKeyframeMerges(operations) {
1923
2044
  if (indices.length < 2)
1924
2045
  continue;
1925
2046
  const first = indices[0];
1926
- // Union keyframes by time; later ops shallow-merge over earlier ones.
1927
- const byTime = new Map();
1928
2047
  let duration = 0;
1929
2048
  let loop = false;
1930
2049
  for (const i of indices) {
1931
2050
  const op = operations[i];
1932
- for (const kf of op.keyframes || []) {
1933
- const prev = byTime.get(kf.time);
1934
- byTime.set(kf.time, {
1935
- time: kf.time,
1936
- properties: { ...(prev?.properties || {}), ...(kf.properties || {}) },
1937
- ...((kf.easing ?? prev?.easing) !== undefined ? { easing: kf.easing ?? prev?.easing } : {}),
1938
- });
1939
- }
1940
2051
  const opMax = op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 0;
1941
2052
  duration = Math.max(duration, op.duration || opMax);
1942
2053
  loop = loop || op.loop === true;
1943
2054
  if (i !== first)
1944
2055
  plan.foldedInto.set(i, first);
1945
2056
  }
2057
+ // B120: at every key time of ANY op, each op contributes its own track SAMPLED there (held outside
2058
+ // its keys, interpolated between them), later ops winning. A plain union by timestamp left a key
2059
+ // holding only one op's properties, so the engine broke the other op's channel at that key.
2060
+ const allTimes = [...new Set(indices.flatMap((i) => (operations[i].keyframes || []).map((k) => k.time)))].sort((a, b) => a - b);
2061
+ const mergedKeyframes = allTimes.map((t) => {
2062
+ let properties = {};
2063
+ let easing;
2064
+ for (const i of indices) {
2065
+ const kfs = operations[i].keyframes || [];
2066
+ properties = { ...properties, ...sampleOpTrack(kfs, t) };
2067
+ const exact = kfs.find((k) => k.time === t);
2068
+ if (exact && exact.easing !== undefined)
2069
+ easing = exact.easing;
2070
+ }
2071
+ return { time: t, properties, ...(easing !== undefined ? { easing } : {}) };
2072
+ });
1946
2073
  plan.merged.set(first, {
1947
- keyframes: [...byTime.values()].sort((a, b) => a.time - b.time),
2074
+ keyframes: mergedKeyframes,
1948
2075
  duration: duration || 5,
1949
2076
  loop,
1950
2077
  });
@@ -3959,8 +4086,19 @@ throw new Error('Unknown diagram mode action: ${action}');
3959
4086
  || (typeof v.reason === 'string' && v.reason)
3960
4087
  || 'the operation reported failure without naming a reason';
3961
4088
  };
3962
- const results = [];
4089
+ // $N is the Nth item THIS batch created (the documented contract). The
4090
+ // previous batch's ids are kept only to name the right id when a $N reaches
4091
+ // past what this batch has made: picking it silently shifted every $N in a
4092
+ // batch that also creates (B115 follow-up).
3963
4093
  const itemIds = [];
4094
+ const __prevBatchIds = ((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids) || []).slice();
4095
+ const __ref = function (n) {
4096
+ if (itemIds[n] !== undefined) return itemIds[n];
4097
+ const prev = __prevBatchIds[n];
4098
+ throw new Error('$' + n + ' is the item number ' + n + ' THIS batch created, and it has created ' + itemIds.length + ' so far.'
4099
+ + (prev ? ' In the previous batch $' + n + ' was ' + prev + ' — pass that id instead.' : ' Put the create ops first, or pass the item id.'));
4100
+ };
4101
+ const results = [];
3964
4102
  let success = true;
3965
4103
 
3966
4104
  try {
@@ -4016,11 +4154,19 @@ throw new Error('Unknown diagram mode action: ${action}');
4016
4154
  // 60-op batch should not have to scan sixty entries to learn that op 41
4017
4155
  // could not find a preset.
4018
4156
  const _failed = results.filter((r) => !r.success);
4157
+ // A batch that created nothing keeps the previous batch's ids on record.
4158
+ if (typeof window !== 'undefined' && itemIds.length) {
4159
+ window.__pinepaper_batch_item_ids = itemIds;
4160
+ }
4019
4161
  return { success, itemIds, results, operationCount: ${operations.length},
4020
4162
  failedCount: _failed.length,
4021
4163
  ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
4022
4164
  canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4023
4165
  } catch (e) {
4166
+ // A batch that created nothing keeps the previous batch's ids on record.
4167
+ if (typeof window !== 'undefined' && itemIds.length) {
4168
+ window.__pinepaper_batch_item_ids = itemIds;
4169
+ }
4024
4170
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4025
4171
  return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4026
4172
  }
@@ -4043,13 +4189,40 @@ throw new Error('Unknown diagram mode action: ${action}');
4043
4189
  // for a 12 s MP4 (round 8 DD, 1.67). It now runs generateCreateItemCode,
4044
4190
  // the exact emitter create_item uses, adapted to return its result
4045
4191
  // inside this op's function.
4046
- const pos = op.position || { x: 400, y: 300 };
4192
+ const anyOp = op;
4193
+ let pos = op.position;
4194
+ if (!pos && (typeof anyOp.x === 'number' || typeof anyOp.y === 'number')) {
4195
+ pos = { x: anyOp.x ?? 400, y: anyOp.y ?? 300 };
4196
+ }
4197
+ if (!pos) {
4198
+ pos = { x: 400, y: 300 };
4199
+ }
4047
4200
  const opProps = { ...(op.properties || {}) };
4201
+ const batchKeys = new Set([
4202
+ 'type', 'itemType', 'position', 'properties', 'screenSpace', 'stepTiming', 'counter',
4203
+ 'itemId', 'itemIds', 'groupName', 'animationType', 'animationOptions',
4204
+ 'sourceId', 'targetId', 'relationType', 'relationOptions',
4205
+ 'backgroundColor', 'generatorName', 'generatorParams', 'generatorRegion',
4206
+ 'width', 'height', 'preset', 'keyframes', 'duration', 'loop',
4207
+ 'maskPreset', 'maskType', 'maskOptions', 'effectType', 'effectParams',
4208
+ 'action', 'time', 'append'
4209
+ ]);
4210
+ for (const [k, v] of Object.entries(anyOp)) {
4211
+ if (!batchKeys.has(k) && v !== undefined && opProps[k] === undefined) {
4212
+ opProps[k] = v;
4213
+ }
4214
+ }
4215
+ if (opProps.content === undefined && opProps.text !== undefined) {
4216
+ opProps.content = opProps.text;
4217
+ }
4218
+ if (opProps.fillColor === undefined && opProps.color !== undefined) {
4219
+ opProps.fillColor = opProps.color;
4220
+ }
4048
4221
  const opSetters = takeItemSetters(opProps, { screenSpace: op.screenSpace, stepTiming: op.stepTiming, counter: op.counter });
4049
4222
  const createProps = withRadiusAxes(opProps);
4050
4223
  const opBrand = { ...opProps };
4051
4224
  delete opProps.noBrand;
4052
- const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined), op.itemType, opBrand));
4225
+ const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined || typeof anyOp.x === 'number' || typeof anyOp.y === 'number'), op.itemType, opBrand));
4053
4226
  let createCode = `${hasItemSetters(opSetters) ? `\n${itemSettersPreflightJs(opSetters)}` : ''}
4054
4227
  const __created = await (async function() {
4055
4228
  ${returning}
@@ -4064,7 +4237,7 @@ ${itemSettersApplyJs(opSetters, '__created.itemId', '__created')}
4064
4237
  // caller explicitly asked for a position. Guarded on an explicit
4065
4238
  // op.position so absolute-coordinate geometry (the common case, which
4066
4239
  // passes no position) is never snapped to the injected default.
4067
- if (op.position && isCoordinateBuilt(op.itemType, createProps)) {
4240
+ if ((op.position || typeof anyOp.x === 'number' || typeof anyOp.y === 'number') && isCoordinateBuilt(op.itemType, createProps)) {
4068
4241
  createCode += `
4069
4242
  // create() builds this item type from its own coordinates and ignores
4070
4243
  // params.position — apply the caller's explicit position after the fact.
@@ -4078,7 +4251,7 @@ return __created;
4078
4251
  }
4079
4252
  case 'modify': {
4080
4253
  const itemRef = op.itemId?.startsWith('$')
4081
- ? `itemIds[${op.itemId.substring(1)}]`
4254
+ ? `__ref(${op.itemId.substring(1)})`
4082
4255
  : `'${op.itemId}'`;
4083
4256
  // modify_item's own emitter, as create is create_item's (see above):
4084
4257
  // the batch's modifyItem call missed text styles, font loading, audio
@@ -4099,7 +4272,7 @@ return __r;
4099
4272
  }
4100
4273
  case 'animate':
4101
4274
  const animItemRef = op.itemId?.startsWith('$')
4102
- ? `itemIds[${op.itemId.substring(1)}]`
4275
+ ? `__ref(${op.itemId.substring(1)})`
4103
4276
  : `'${op.itemId}'`;
4104
4277
  const animOpts = JSON.stringify(op.animationOptions || {});
4105
4278
  return `
@@ -4111,10 +4284,10 @@ return { itemId: targetId, animationType: '${op.animationType}' };
4111
4284
  `;
4112
4285
  case 'relation':
4113
4286
  const srcRef = op.sourceId?.startsWith('$')
4114
- ? `itemIds[${op.sourceId.substring(1)}]`
4287
+ ? `__ref(${op.sourceId.substring(1)})`
4115
4288
  : `'${op.sourceId}'`;
4116
4289
  const tgtRef = op.targetId?.startsWith('$')
4117
- ? `itemIds[${op.targetId.substring(1)}]`
4290
+ ? `__ref(${op.targetId.substring(1)})`
4118
4291
  : `'${op.targetId}'`;
4119
4292
  const relOpts = JSON.stringify(op.relationOptions || {});
4120
4293
  return `
@@ -4129,7 +4302,7 @@ return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4129
4302
  `;
4130
4303
  case 'delete':
4131
4304
  const delRef = op.itemId?.startsWith('$')
4132
- ? `itemIds[${op.itemId.substring(1)}]`
4305
+ ? `__ref(${op.itemId.substring(1)})`
4133
4306
  : `'${op.itemId}'`;
4134
4307
  return `
4135
4308
  const targetId = ${delRef};
@@ -4141,7 +4314,7 @@ return { itemId: targetId, deleted: true };
4141
4314
  `;
4142
4315
  case 'group': {
4143
4316
  // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
4144
- const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `itemIds[${ref.substring(1)}]` : `'${ref}'`);
4317
+ const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `__ref(${ref.substring(1)})` : `'${ref}'`);
4145
4318
  const idsArrayExpr = `[${refs.join(', ')}]`;
4146
4319
  const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
4147
4320
  // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
@@ -4151,7 +4324,7 @@ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4151
4324
  `;
4152
4325
  }
4153
4326
  case 'set_background':
4154
- const bgColor = op.backgroundColor || op.properties?.color || '#000000';
4327
+ const bgColor = op.backgroundColor || op.color || op.properties?.backgroundColor || op.properties?.color || '#000000';
4155
4328
  return `
4156
4329
  app.setBackgroundColor('${bgColor}');
4157
4330
  return { success: true, backgroundColor: '${bgColor}' };
@@ -4185,28 +4358,54 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
4185
4358
  return `
4186
4359
  // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
4187
4360
  // is replaced wholesale, so emitting both here would silently drop one.
4188
- return { itemId: ${op.itemId?.startsWith('$') ? `itemIds[${op.itemId.substring(1)}]` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
4361
+ return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)})` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
4189
4362
  `;
4190
4363
  }
4191
4364
  const mergedPayload = mergePlan?.merged.get(index);
4192
- const kfItemRef = op.itemId?.startsWith('$')
4193
- ? `itemIds[${op.itemId.substring(1)}]`
4194
- : `'${op.itemId}'`;
4195
- const kfSource = mergedPayload ? mergedPayload.keyframes : (op.keyframes || []);
4365
+ const rawItemId = String(op.itemId ?? op.id ?? op.properties?.itemId ?? op.properties?.id);
4366
+ const kfItemRef = rawItemId?.startsWith('$')
4367
+ ? `__ref(${rawItemId.substring(1)})`
4368
+ : `'${rawItemId}'`;
4369
+ const kfSource = mergedPayload
4370
+ ? mergedPayload.keyframes
4371
+ : (op.keyframes || (op.properties && op.properties.keyframes) || (op.params && op.params.keyframes) || []);
4196
4372
  const kfJson = JSON.stringify(normalizeKeyframePositions(kfSource));
4197
4373
  const kfDuration = mergedPayload
4198
4374
  ? mergedPayload.duration
4199
- : (op.duration || (op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 5));
4200
- const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? false);
4375
+ : (op.duration || op.properties?.duration || (kfSource.length ? Math.max(...kfSource.map((k) => Number(k.time) || 0)) : 5));
4376
+ const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? op.properties?.loop ?? false);
4377
+ // Same contract as pinepaper_keyframe_animate: replace unless append.
4378
+ const kfAppend = op.append === true || op.properties?.append === true;
4379
+ // Batch ops are passthrough, so these arrive unvalidated: numbers only, and timeUnits from its enum.
4380
+ const kfExtra = [];
4381
+ for (const key of ['timeOffset', 'clipInPoint', 'clipOutPoint']) {
4382
+ const v = Number(op[key]);
4383
+ if (op[key] !== undefined && Number.isFinite(v))
4384
+ kfExtra.push(`${key}: ${v}`);
4385
+ }
4386
+ const kfUnits = op.timeUnits;
4387
+ if (kfUnits === 'seconds' || kfUnits === 'ms')
4388
+ kfExtra.push(`timeUnits: '${kfUnits}'`);
4389
+ const kfExtraStr = kfExtra.length > 0 ? `, ${kfExtra.join(', ')}` : '';
4390
+ const kfUnsupported = [...new Set((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})))]
4391
+ .filter((k) => k === 'letterSpacing');
4392
+ const kfIgnored = kfUnsupported.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(kfUnsupported)}, warning: ${JSON.stringify(`${kfUnsupported.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
4201
4393
  return `
4202
4394
  const targetId = ${kfItemRef};
4203
- app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop} });
4204
- return { itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop} };
4395
+ const _target = (app.getItemById && app.getItemById(targetId))
4396
+ || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(targetId));
4397
+ if (!_target) {
4398
+ return { success: false, itemId: targetId, error: 'Item not found: ' + targetId };
4399
+ }
4400
+ const _kfItem = _target.item || _target;
4401
+ const _prevKeys = (_kfItem.data && Array.isArray(_kfItem.data.keyframes)) ? _kfItem.data.keyframes.length : 0;
4402
+ app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ", mode: 'merge'" : ''}${kfExtraStr} });
4403
+ return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ', append: true' : ''}${kfAppend ? '' : `, ...(_prevKeys ? { track: { replaced: true, previousKeys: _prevKeys, note: 'the item already had a keyframe track; this op replaced it. To add keys to it instead, pass append: true.' } } : {})`}${kfIgnored} };
4205
4404
  `;
4206
4405
  }
4207
4406
  case 'apply_mask': {
4208
4407
  const maskItemRef = op.itemId?.startsWith('$')
4209
- ? `itemIds[${op.itemId.substring(1)}]`
4408
+ ? `__ref(${op.itemId.substring(1)})`
4210
4409
  : `'${op.itemId}'`;
4211
4410
  const maskOpts = JSON.stringify(op.maskOptions || {});
4212
4411
  const preset = op.maskPreset || op.maskType || 'wipeLeft';
@@ -4225,7 +4424,7 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
4225
4424
  }
4226
4425
  case 'apply_effect': {
4227
4426
  const effItemRef = op.itemId?.startsWith('$')
4228
- ? `itemIds[${op.itemId.substring(1)}]`
4427
+ ? `__ref(${op.itemId.substring(1)})`
4229
4428
  : `'${op.itemId}'`;
4230
4429
  const effParams = JSON.stringify(op.effectParams || {});
4231
4430
  return `
@@ -4610,34 +4809,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4610
4809
  // warnings:[] plus a note when it checked and found nothing, and the
4611
4810
  // warnings themselves otherwise. The note carries the caveat that absence
4612
4811
  // was standing in for.
4613
- //
4614
- // STAND-INS ARE THIS TOOL'S OWN DOING, SO THIS TOOL REPORTS THEM. create_item
4615
- // draws 'shader' and 'field' locally as a flat plate tagged data.renderAs; the
4616
- // engine's check sees an ordinary rectangle and cannot know a surface was
4617
- // meant. So a local export of a sea came out a flat blue slab with nothing
4618
- // said. Reported whatever the engine check can or cannot do.
4619
- function standIns() {
4620
- try {
4621
- // Probed with === : a studio without it degrades to "no stand-ins".
4622
- const canList = app.itemRegistry && typeof app.itemRegistry.getAll === 'function';
4623
- if (!canList) return [];
4624
- const found = {};
4625
- for (const entry of app.itemRegistry.getAll()) {
4626
- const kind = entry && entry.item && entry.item.data && entry.item.data.renderAs;
4627
- if (kind === 'shader' || kind === 'field') (found[kind] = found[kind] || []).push(entry.itemId);
4628
- }
4629
- return Object.keys(found).map(function(kind) {
4630
- const ids = found[kind];
4631
- return {
4632
- code: 'render_time_surface_stand_in',
4633
- message: ids.length + ' ' + kind + ' item' + (ids.length > 1 ? 's are' : ' is')
4634
- + ' exported as the flat plate that stands in for ' + (ids.length > 1 ? 'them' : 'it')
4635
- + ' on a local canvas. The surface itself is drawn only by a cloud render.',
4636
- items: ids.slice(0, 6),
4637
- };
4638
- });
4639
- } catch (e) { return []; }
4640
- }
4812
+
4641
4813
  // TRANSPARENCY THE FORMAT CANNOT CARRY. A scene with no background exported
4642
4814
  // to webm came out on BLACK while the engine's check said it lost nothing
4643
4815
  // (round 7, 8.12). Only said when the scene is evidently transparent — no
@@ -4676,7 +4848,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4676
4848
  } catch (e) { return []; }
4677
4849
  }
4678
4850
  function fidelity(fmt) {
4679
- const own = standIns().concat(alphaLoss(fmt), textOverflow());
4851
+ const own = alphaLoss(fmt).concat(textOverflow());
4680
4852
  try {
4681
4853
  if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') {
4682
4854
  return { fidelity: { available: false, reason: 'this studio cannot check export fidelity — update PinePaper Studio.', ...(own.length ? { warnings: own } : {}) } };
@@ -9598,6 +9770,181 @@ ${resolve}
9598
9770
  }
9599
9771
  }
9600
9772
  // ===========================================================================
9773
+ // HAND-DRAWN SKETCHY ANIMATION & JITTER
9774
+ // ===========================================================================
9775
+ generateHandDrawn(input) {
9776
+ const S = (v) => JSON.stringify(v);
9777
+ // style carries no schema default: with one, `mode` (its alias) was never read.
9778
+ const requested = input.style ?? input.mode ?? 'sketchy';
9779
+ const style = requested === 'ink_reveal' ? 'reveal' : requested;
9780
+ const roughness = input.roughness ?? 2;
9781
+ const bowing = input.bowing ?? 1;
9782
+ const fps = input.fps ?? input.posterizeFps ?? 12;
9783
+ const seed = input.seed ?? 1;
9784
+ const duration = input.duration ?? 2;
9785
+ // WOBBLE AND STOP_MOTION ANSWERED SUCCESS AND EXPORTED NOTHING. The engine's
9786
+ // wobble draws one frame per call (updateHandDrawnWobble(item, frameIndex)),
9787
+ // so a single call is a static jitter; stop_motion is a per-frame timing
9788
+ // helper with no item to apply to, and applyHandDrawnStyle throws for it by
9789
+ // name. The fallback hung an onFrame on the path, which is neither saved
9790
+ // nor exported. The boiling line the studio does save and export is the ink
9791
+ // medium, so the refusal hands over that call with these arguments.
9792
+ if (style === 'wobble' || style === 'stop_motion') {
9793
+ const use = {
9794
+ tool: 'pinepaper_design_medium',
9795
+ args: { action: 'apply', itemId: input.itemId ?? '<itemId>', medium: 'ink', options: { keepColor: true, fps, seed, amplitude: roughness } },
9796
+ };
9797
+ const error = `'${style}' has no animated form this studio saves or exports, so it would have shown in the editor and not in a video. Nothing was changed. For a hand-drawn line that boils frame to frame in exports, call pinepaper_design_medium with the arguments in \`use\` (fps is the boil rate). For a one-off rough line, use style 'sketchy'.`;
9798
+ return `(function() { return { success: false, style: ${S(style)}, error: ${S(error)}, use: ${S(use)} }; })();`;
9799
+ }
9800
+ // REVEAL WAS AN onFrame DASH ANIMATION, which no export plays. A draw-on the
9801
+ // studio saves is a trimEnd 0 -> 1 keyframe track, merged so the item's
9802
+ // other keyframes survive.
9803
+ if (style === 'reveal') {
9804
+ if (!input.itemId) {
9805
+ return `(function() { return { success: false, style: 'reveal', error: 'reveal needs an itemId: it keyframes that item\\'s stroke. Nothing was changed.' }; })();`;
9806
+ }
9807
+ // A trimEnd track on text or an image is kept and draws nothing (measured
9808
+ // on prod: a PointText answered success and never changed), so the item
9809
+ // is checked for a stroke first.
9810
+ const track = generateKeyframeAnimateCode(input.itemId, [
9811
+ { time: 0, properties: { trimEnd: 0 }, easing: 'linear' },
9812
+ { time: duration, properties: { trimEnd: 1 }, easing: 'linear' },
9813
+ ], duration, false, undefined, undefined, undefined, undefined, true).replace(/;\s*$/, '');
9814
+ return `
9815
+ // Hand-drawn draw-on: a trimEnd track on a stroked path
9816
+ (function() {
9817
+ const found = app.getItemById(${S(input.itemId)}) || (app.itemRegistry && app.itemRegistry.get(${S(input.itemId)}));
9818
+ const it = found && found.item ? found.item : found;
9819
+ const isPath = function(p) { return !!(p && p.segments && p.segments.length); };
9820
+ if (it && !isPath(it) && !(it.children && it.children.some(isPath))) {
9821
+ return { success: false, itemId: ${S(input.itemId)}, error: 'reveal draws a stroke on, and this item has no path to draw (' + (it.className || 'not a path') + '). Nothing was changed. For words that write themselves on, use pinepaper_text_style cursive with animate: true.' };
9822
+ }
9823
+ return (
9824
+ ${track}
9825
+ );
9826
+ })();`.trim();
9827
+ }
9828
+ const targetId = input.itemId ? S(input.itemId) : 'null';
9829
+ return `
9830
+ // Hand-drawn sketchy stroke jitter
9831
+ (function() {
9832
+ const targetId = ${targetId};
9833
+ const found = targetId ? (app.getItemById(targetId) || (app.itemRegistry && app.itemRegistry.get(targetId))) : (app.selectedItems && app.selectedItems[0]);
9834
+ const item = found && found.item ? found.item : found;
9835
+ if (!item) {
9836
+ return { success: false, error: 'Target item not found for hand-drawn styling' };
9837
+ }
9838
+ // The registry id, which every other tool takes — not Paper's numeric item.id.
9839
+ const itemId = targetId || (item.data && (item.data.registryId || item.data.id)) || null;
9840
+
9841
+ // 1. The engine's own sketchy jitter.
9842
+ if (typeof HandDrawn !== 'undefined' && HandDrawn && typeof HandDrawn.applyHandDrawnStyle === 'function') {
9843
+ try {
9844
+ HandDrawn.applyHandDrawnStyle(item, 'sketchy', { roughness: ${roughness}, bowing: ${bowing}, seed: ${seed} });
9845
+ if (app._scheduleRepaint) app._scheduleRepaint();
9846
+ if (app.historyManager) app.historyManager.saveState();
9847
+ return { success: true, itemId: itemId, style: 'sketchy', engine: 'HandDrawn' };
9848
+ } catch (e) {
9849
+ console.warn('[HandDrawn] Engine error, falling back to procedural jitter:', e);
9850
+ }
9851
+ }
9852
+
9853
+ if (typeof app.applyHandDrawnJitter === 'function') {
9854
+ app.applyHandDrawnJitter(item, { roughness: ${roughness}, bowing: ${bowing}, seed: ${seed} });
9855
+ if (app._scheduleRepaint) app._scheduleRepaint();
9856
+ if (app.historyManager) app.historyManager.saveState();
9857
+ return { success: true, itemId: itemId, style: 'sketchy', engine: 'app.applyHandDrawnJitter' };
9858
+ }
9859
+
9860
+ // 2. Procedural mulberry32 PRNG vertex jitter fallback
9861
+ function mulberry32(a) {
9862
+ return function() {
9863
+ var t = a += 0x6D2B79F5;
9864
+ t = Math.imul(t ^ t >>> 15, t | 1);
9865
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
9866
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
9867
+ };
9868
+ }
9869
+
9870
+ const targets = (item.children && item.children.length) ? item.children : [item];
9871
+ let moved = 0;
9872
+ targets.forEach((t, itemIdx) => {
9873
+ if (!t.segments || !t.segments.length) return;
9874
+ const rand = mulberry32(${seed} + itemIdx * 777);
9875
+ for (let i = 0; i < t.segments.length; i++) {
9876
+ const seg = t.segments[i];
9877
+ seg.point.x += (rand() - 0.5) * ${roughness} * 2;
9878
+ seg.point.y += (rand() - 0.5) * ${roughness} * 2;
9879
+ if (seg.handleIn && (seg.handleIn.x !== 0 || seg.handleIn.y !== 0)) {
9880
+ seg.handleIn.x += (rand() - 0.5) * ${bowing} * 1.5;
9881
+ seg.handleIn.y += (rand() - 0.5) * ${bowing} * 1.5;
9882
+ }
9883
+ if (seg.handleOut && (seg.handleOut.x !== 0 || seg.handleOut.y !== 0)) {
9884
+ seg.handleOut.x += (rand() - 0.5) * ${bowing} * 1.5;
9885
+ seg.handleOut.y += (rand() - 0.5) * ${bowing} * 1.5;
9886
+ }
9887
+ moved++;
9888
+ }
9889
+ });
9890
+ if (!moved) {
9891
+ return { success: false, itemId: itemId, error: 'sketchy moves path points, and this item has none (text or an image?). Nothing was changed.' };
9892
+ }
9893
+
9894
+ if (app._scheduleRepaint) app._scheduleRepaint();
9895
+ if (app.historyManager) app.historyManager.saveState();
9896
+ return { success: true, itemId: itemId, style: 'sketchy', roughness: ${roughness}, points: moved };
9897
+ })();`.trim();
9898
+ }
9899
+ // ===========================================================================
9900
+ // PEN FIGURES
9901
+ // ===========================================================================
9902
+ generatePenFigure(input) {
9903
+ const S = (v) => JSON.stringify(v);
9904
+ const missing = `if (typeof app.listFigures !== 'function' || typeof app.createFigure !== 'function') {
9905
+ return { success: false, error: 'This studio has no pen figures (app.createFigure). Nothing was drawn.' };
9906
+ }`;
9907
+ if (input.action === 'list') {
9908
+ return `
9909
+ // The pen figure catalogue
9910
+ (function() {
9911
+ ${missing}
9912
+ const figures = app.listFigures();
9913
+ return { success: true, action: 'list', count: figures.length, figures: figures };
9914
+ })();`.trim();
9915
+ }
9916
+ if (input.figure === undefined) {
9917
+ return `(function() { return { success: false, error: "figure is required: a catalogue id (action 'list' names them) or an inline figure spec. Nothing was drawn." }; })();`;
9918
+ }
9919
+ const opts = {};
9920
+ for (const k of ['box', 'margin', 'duration', 'delay', 'loop', 'pen', 'strokeColor', 'penColor', 'strokeWidth']) {
9921
+ if (input[k] !== undefined)
9922
+ opts[k] = input[k];
9923
+ }
9924
+ // createFigure answers null for an unknown id or a bad spec, and says why
9925
+ // only in a console.warn, which production strips. The refusal is built
9926
+ // here instead, naming the catalogue.
9927
+ return `
9928
+ // Draw a figure with a pen (draws_figure)
9929
+ (function() {
9930
+ ${missing}
9931
+ const figure = ${S(input.figure)};
9932
+ const out = app.createFigure(figure, ${S(opts)});
9933
+ if (!out || !out.ink) {
9934
+ const known = app.listFigures().map(function(f) { return f.id; });
9935
+ return { success: false, error: (typeof figure === 'string' && known.indexOf(figure) < 0
9936
+ ? 'No figure "' + figure + '". Known: ' + known.join(', ') + '.'
9937
+ : 'The figure spec could not be drawn (it produced no strokes or did not parse).') + ' Nothing was drawn.' };
9938
+ }
9939
+ if (app._scheduleRepaint) app._scheduleRepaint();
9940
+ if (app.historyManager) app.historyManager.saveState();
9941
+ return out.pen
9942
+ ? { success: true, inkId: out.ink, penId: out.pen, title: out.title, strokes: out.strokes, duration: ${S(input.duration ?? 6)},
9943
+ note: 'The pen draws the ink over duration seconds (relation draws_figure, pen → ink).' }
9944
+ : { success: true, inkId: out.ink, penId: null, title: out.title, strokes: out.strokes, note: 'pen: false — the ink is fully drawn, with no pen and no animation.' };
9945
+ })();`.trim();
9946
+ }
9947
+ // ===========================================================================
9601
9948
  // SPRITE SHEETS
9602
9949
  // ===========================================================================
9603
9950
  generateSpriteSheet(input) {
@@ -9971,8 +10318,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
9971
10318
  // Add physics body
9972
10319
  (function() {
9973
10320
  ${guard}
9974
- const entry = app.itemRegistry.get(${itemIdStr});
9975
- if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
10321
+ const entry = (app.getItemById && app.getItemById(${itemIdStr})) || (app.itemRegistry && (app.itemRegistry.getItem ? app.itemRegistry.getItem(${itemIdStr}) : app.itemRegistry.get(${itemIdStr})));
10322
+ const it = entry && (entry.item || entry);
10323
+ if (!it) return { error: 'Item not found: ' + ${itemIdStr} };
9976
10324
  const ok = app.physicsWorld.addBody(${itemIdStr}, ${JSON.stringify(config)});
9977
10325
  return { success: ok, action: 'add_body', itemId: ${itemIdStr}, bodyType: ${JSON.stringify(input.bodyType || 'dynamic')} };
9978
10326
  })();`.trim();
@@ -11242,7 +11590,7 @@ ${guard}
11242
11590
  const __sc = __it.scaling || { x: 1, y: 1 };
11243
11591
  const __b = __it.bounds;
11244
11592
  const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
11245
- const __fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11593
+ ${authoringFrameJs('__fr')}
11246
11594
  const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
11247
11595
  const __actor = { x: __it.position.x, y: __it.position.y,
11248
11596
  w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
@@ -11263,6 +11611,7 @@ ${guard}
11263
11611
  __warnings.push('the actor already animates ' + __other.join(', ') + '; the beat keys leave those out, which the engine can break at the beat keys. Re-key ' + __other.join(', ') + ' with every key carrying them, after choreographing.');
11264
11612
  }
11265
11613
  if (__r.final && !__r.final.onScreen) __warnings.push('after the last beat the actor is off screen at [' + __r.final.x + ', ' + __r.final.y + '].');
11614
+ if (__fr.unbounded && __warnings.some(function (w) { return /off screen/.test(w); })) __warnings.push('the canvas is unbounded, so it has no fixed frame: on-screen was checked against its nominal ' + __fr.width + 'x' + __fr.height + ' artboard. Set a canvas size or preset to fix the frame.');
11266
11615
  const __res = { success: true, itemId: ${id}, beats: ${input.beats.length}, keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };
11267
11616
  ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —
11268
11617
  // pop, launch, contact, shake — loud as the moment is strong. A sound that
@@ -11322,6 +11671,7 @@ ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the
11322
11671
  if (!tid) {
11323
11672
  const p = Object.assign({}, st.shape.properties || {}, { x: st.shape.position.x, y: st.shape.position.y });
11324
11673
  const made = app.create(st.shape.itemType, p);
11674
+ if (made) { made.visible = false; }
11325
11675
  tid = made && made.data && made.data.registryId;
11326
11676
  if (!tid) { return { success: false, error: 'state ' + i + ': could not make the ' + st.shape.itemType + ' reference shape', states: done }; }
11327
11677
  } else if (!(app.getItemById && app.getItemById(tid))) {
@@ -11336,7 +11686,7 @@ ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the
11336
11686
  if (n !== null && n < done.length) res.warning = 'only ' + n + ' of ' + done.length + ' morphs are on the element.';
11337
11687
  ${cam ? ` // The camera rides the SAME timing: framing each state as its morph lands.
11338
11688
  {
11339
- const fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11689
+ ${authoringFrameJs('fr')}
11340
11690
  const fill = ${S(cam.fill ?? 0.45)};
11341
11691
  const frameOf = function (it) { const b = it.bounds; return { focus: [b.center.x, b.center.y], zoom: Math.max(0.2, Math.min(8, fill * Math.min(fr.width / Math.max(1, b.width), fr.height / Math.max(1, b.height)))) }; };
11342
11692
  const f0 = frameOf(src);
@@ -11401,6 +11751,154 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
11401
11751
  }
11402
11752
  }
11403
11753
  }
11754
+ /**
11755
+ * Character tool aliases (pinepaper_create_character, pinepaper_character_action, pinepaper_character_pose)
11756
+ * Routing on-device / local model calls cleanly to the engine.
11757
+ */
11758
+ generateCreateCharacter(input) {
11759
+ const S = (v) => JSON.stringify(v);
11760
+ const charId = S(input.characterId || input.name || `char_${Date.now()}`);
11761
+ const posX = typeof input.x === 'number' ? input.x : 400;
11762
+ const posY = typeof input.y === 'number' ? input.y : 300;
11763
+ const scale = typeof input.scale === 'number' ? input.scale : 1;
11764
+ const name = S(input.name || input.characterId || 'Character');
11765
+ const base = input.base ? S(input.base) : 'null';
11766
+ const preset = input.preset || input.concept ? S(input.preset || input.concept) : 'null';
11767
+ return `
11768
+ // Create Character
11769
+ (async function() {
11770
+ const charId = ${charId};
11771
+ const posX = ${posX};
11772
+ const posY = ${posY};
11773
+ const scale = ${scale};
11774
+ const name = ${name};
11775
+ const base = ${base};
11776
+ const preset = ${preset};
11777
+
11778
+ // 1. If original character base is specified and app.createCharacter exists
11779
+ if (base && typeof app.createCharacter === 'function') {
11780
+ const r = await app.createCharacter({ base: base, at: { x: posX, y: posY }, scale: scale });
11781
+ if (r && r.ok !== false) {
11782
+ return { success: true, characterId: charId, itemId: r.id, parts: r.parts };
11783
+ }
11784
+ }
11785
+
11786
+ // 2. Try app.create
11787
+ let groupId = null;
11788
+ if (typeof app.create === 'function') {
11789
+ const group = app.create('circle', { x: posX, y: posY, radius: 35 * scale, color: '#7C3AED' });
11790
+ if (group) {
11791
+ group.data = group.data || {};
11792
+ group.data.ppType = 'pp:Character';
11793
+ group.data.characterId = charId;
11794
+ group.name = name;
11795
+ groupId = group.id || (group.data && group.data.id) || charId;
11796
+ }
11797
+ } else {
11798
+ const p = typeof paper !== 'undefined' ? paper : ((typeof window !== 'undefined' ? window : globalThis).paper);
11799
+ if (p && p.Path) {
11800
+ const body = new p.Path.Circle({ center: [posX, posY], radius: 35 * scale, fillColor: '#7C3AED' });
11801
+ const head = new p.Path.Circle({ center: [posX, posY - 45 * scale], radius: 20 * scale, fillColor: '#EC4899' });
11802
+ const group = new p.Group([body, head]);
11803
+ group.name = name;
11804
+ if (app.textItemGroup) group.parent = app.textItemGroup;
11805
+ group.data = { isUserGroup: true, isLayeredCharacter: true, ppType: 'pp:Character', characterId: charId };
11806
+ const reg = app.itemRegistry;
11807
+ groupId = reg ? reg.register(group, 'group', { source: 'mcp_create_character' }, 'mcp') : charId;
11808
+ group.data.id = groupId;
11809
+ }
11810
+ }
11811
+
11812
+ if (app._scheduleRepaint) app._scheduleRepaint();
11813
+ if (app.historyManager) app.historyManager.saveState();
11814
+ return { success: true, characterId: charId, itemId: groupId };
11815
+ })();`.trim();
11816
+ }
11817
+ generateCharacterAction(input) {
11818
+ const S = (v) => JSON.stringify(v);
11819
+ const targetId = input.characterId || input.itemId;
11820
+ const act = String(input.action || 'walk').toLowerCase();
11821
+ const speed = input.speed || 100;
11822
+ return `
11823
+ // Character Action
11824
+ (function() {
11825
+ const targetId = ${S(targetId)};
11826
+ const act = ${S(act)};
11827
+ const speed = ${speed};
11828
+
11829
+ const charItem = targetId
11830
+ ? (app.getItemById(targetId) || (app.getAllItems && app.getAllItems().find(i => (i.data && i.data.characterId === targetId) || i.name === targetId)))
11831
+ : (app.selectedItems && app.selectedItems[0]);
11832
+
11833
+ if (!charItem) {
11834
+ return { success: false, error: 'Character not found: ' + targetId };
11835
+ }
11836
+
11837
+ const itemId = charItem.id || (charItem.data && charItem.data.id) || targetId;
11838
+ const skelId = (charItem.data && charItem.data.characterId) ? 'character_' + charItem.data.characterId : null;
11839
+
11840
+ if (act === 'walk' || act === 'run' || act === 'locomotion') {
11841
+ if (typeof app.addRelation === 'function') {
11842
+ app.addRelation(itemId, null, 'locomotion', {
11843
+ skeletonId: skelId,
11844
+ speed: speed,
11845
+ cycleDistance: 150,
11846
+ });
11847
+ }
11848
+ } else if (app.riggingSystem) {
11849
+ const rs = app.riggingSystem;
11850
+ if (skelId && rs.skeletons && rs.skeletons.has(skelId)) {
11851
+ if (typeof rs.applyBoneAngles === 'function') {
11852
+ rs.applyBoneAngles(skelId, { spine: 0.1, left_arm: -0.5, right_arm: 0.5 });
11853
+ if (typeof rs.solveFK === 'function') rs.solveFK(skelId);
11854
+ }
11855
+ }
11856
+ }
11857
+
11858
+ if (typeof app.animate === 'function' && (act === 'wave' || act === 'dance' || act === 'jump' || act === 'idle')) {
11859
+ app.animate(charItem, { animationType: act === 'jump' ? 'bounce' : (act === 'idle' ? 'breathe' : 'wobble') });
11860
+ }
11861
+
11862
+ if (app._scheduleRepaint) app._scheduleRepaint();
11863
+ return { success: true, characterId: targetId || itemId, action: act };
11864
+ })();`.trim();
11865
+ }
11866
+ generateCharacterPose(input) {
11867
+ const S = (v) => JSON.stringify(v);
11868
+ const targetId = input.characterId || input.itemId;
11869
+ const expr = String(input.expression || 'smile').toLowerCase();
11870
+ const duration = input.duration !== undefined ? input.duration : null;
11871
+ const interval = input.interval !== undefined ? input.interval : null;
11872
+ return `
11873
+ // Character Pose / Expression
11874
+ (function() {
11875
+ const targetId = ${S(targetId)};
11876
+ const expr = ${S(expr)};
11877
+
11878
+ const charItem = targetId
11879
+ ? (app.getItemById(targetId) || (app.getAllItems && app.getAllItems().find(i => (i.data && i.data.characterId === targetId) || i.name === targetId)))
11880
+ : (app.selectedItems && app.selectedItems[0]);
11881
+
11882
+ if (!charItem) {
11883
+ return { success: false, error: 'Character not found: ' + targetId };
11884
+ }
11885
+
11886
+ const itemId = charItem.id || (charItem.data && charItem.data.id) || targetId;
11887
+
11888
+ // Touch riggingSystem first to ensure expression rules are initialized
11889
+ const rs = app.riggingSystem;
11890
+ if (typeof app.addRelation === 'function') {
11891
+ app.addRelation(itemId, null, 'expresses', {
11892
+ expression: expr,
11893
+ duration: ${duration === null ? 'null' : duration},
11894
+ interval: ${interval === null ? 'null' : interval},
11895
+ });
11896
+ }
11897
+
11898
+ if (app._scheduleRepaint) app._scheduleRepaint();
11899
+ return { success: true, characterId: targetId || itemId, expression: expr };
11900
+ })();`.trim();
11901
+ }
11404
11902
  /**
11405
11903
  * pinepaper_look (D64): one look over an item, a subtree or the whole scene
11406
11904
  * — ids, keys and relations kept, removable pixel for pixel. Every apply