@pinepaper.studio/mcp-server 1.6.21 → 1.6.23

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 (58) hide show
  1. package/README.md +50 -2
  2. package/dist/build-info.json +2 -2
  3. package/dist/design/design-systems.d.ts +50 -0
  4. package/dist/design/design-systems.d.ts.map +1 -1
  5. package/dist/design/design-systems.js +220 -35
  6. package/dist/design/design-systems.js.map +1 -1
  7. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  8. package/dist/ontology/upstream-vocabulary.js +1 -1
  9. package/dist/tools/choreograph.d.ts +4 -0
  10. package/dist/tools/choreograph.d.ts.map +1 -1
  11. package/dist/tools/choreograph.js +10 -2
  12. package/dist/tools/choreograph.js.map +1 -1
  13. package/dist/tools/definitions.d.ts.map +1 -1
  14. package/dist/tools/definitions.js +36 -11
  15. package/dist/tools/definitions.js.map +1 -1
  16. package/dist/tools/engine-lists.d.ts +2 -2
  17. package/dist/tools/engine-lists.js +2 -2
  18. package/dist/tools/engine-methods.d.ts.map +1 -1
  19. package/dist/tools/engine-methods.js +2 -0
  20. package/dist/tools/engine-methods.js.map +1 -1
  21. package/dist/tools/engine-surface.d.ts +1 -1
  22. package/dist/tools/engine-surface.d.ts.map +1 -1
  23. package/dist/tools/engine-surface.js +21 -6
  24. package/dist/tools/engine-surface.js.map +1 -1
  25. package/dist/tools/handlers/brand-intake.d.ts.map +1 -1
  26. package/dist/tools/handlers/brand-intake.js +42 -1
  27. package/dist/tools/handlers/brand-intake.js.map +1 -1
  28. package/dist/tools/handlers.d.ts +18 -1
  29. package/dist/tools/handlers.d.ts.map +1 -1
  30. package/dist/tools/handlers.js +124 -27
  31. package/dist/tools/handlers.js.map +1 -1
  32. package/dist/tools/shape-params.d.ts +2 -2
  33. package/dist/tools/shape-params.d.ts.map +1 -1
  34. package/dist/tools/shape-params.js +8 -2
  35. package/dist/tools/shape-params.js.map +1 -1
  36. package/dist/tools/stick-vocabulary.d.ts +1 -1
  37. package/dist/tools/stick-vocabulary.js +1 -1
  38. package/dist/types/code-generator.d.ts +36 -1
  39. package/dist/types/code-generator.d.ts.map +1 -1
  40. package/dist/types/code-generator.js +353 -44
  41. package/dist/types/code-generator.js.map +1 -1
  42. package/dist/types/schemas.d.ts +52 -1
  43. package/dist/types/schemas.d.ts.map +1 -1
  44. package/dist/types/schemas.js +15 -0
  45. package/dist/types/schemas.js.map +1 -1
  46. package/dist/utils/asset-paths.d.ts +11 -0
  47. package/dist/utils/asset-paths.d.ts.map +1 -0
  48. package/dist/utils/asset-paths.js +67 -0
  49. package/dist/utils/asset-paths.js.map +1 -0
  50. package/dist/utils/safe-fetch.d.ts +28 -0
  51. package/dist/utils/safe-fetch.d.ts.map +1 -0
  52. package/dist/utils/safe-fetch.js +221 -0
  53. package/dist/utils/safe-fetch.js.map +1 -0
  54. package/dist/vendor/design/bauhaus-generator.d.ts.map +1 -1
  55. package/dist/vendor/design/bauhaus-generator.js +20 -5
  56. package/dist/vendor/design/bauhaus-generator.js.map +1 -1
  57. package/manifest.json +1 -1
  58. package/package.json +6 -3
@@ -74,17 +74,43 @@ 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
- // For radial gradients, origin defaults to the item center (position.x, position.y),
79
- // not an offset (-50, -50), and destination to the radius edge (B88).
80
- const defaultOrigin = origin || (type === 'radial' ? [position.x, position.y] : [position.x - 50, position.y - 50]);
81
- const defaultDestination = destination || (type === 'radial' ? [position.x + 50, position.y] : [position.x + 50, position.y + 50]);
82
77
  const stopsCode = stops.map(s => `['${s.color}', ${s.offset}]`).join(', ');
78
+ if (type === 'radial') {
79
+ // A RADIAL CORE BELONGS ON THE ITEM, NOT ON THE NUMBER ASKED FOR (17
80
+ // RADIALOFFSET). The origin was the REQUESTED position and the radius a
81
+ // fixed 50 px: whenever (x, y) placed the item as a corner (an anchor, a
82
+ // default position) the core landed up-left by half the item, and a 300 px
83
+ // circle got a 50 px core. Read from the created item's bounds instead —
84
+ // centre and right-centre, as the engine's own _parseColor does — with the
85
+ // request only as the fallback when the item has no bounds. An origin or
86
+ // destination the caller gave is kept as given.
87
+ const fx = position.x, fy = position.y;
88
+ const o = origin ? `[${origin[0]}, ${origin[1]}]` : `(__gb ? [__gb.x + __gb.width / 2, __gb.y + __gb.height / 2] : [${fx}, ${fy}])`;
89
+ const d = destination ? `[${destination[0]}, ${destination[1]}]`
90
+ : origin ? `(__gb ? [${origin[0]} + __gb.width / 2, ${origin[1]}] : [${origin[0]} + 50, ${origin[1]}])`
91
+ : `(__gb ? [__gb.x + __gb.width, __gb.y + __gb.height / 2] : [${fx + 50}, ${fy}])`;
92
+ return `
93
+ ${itemVarName}.${propertyName} = (function() {
94
+ const __b = ${itemVarName} && ${itemVarName}.bounds;
95
+ const __gb = __b && isFinite(__b.x) && isFinite(__b.y) && __b.width > 0 && __b.height > 0 ? __b : null;
96
+ return {
97
+ gradient: {
98
+ stops: [${stopsCode}],
99
+ radial: true
100
+ },
101
+ origin: ${o},
102
+ destination: ${d}
103
+ };
104
+ })();`;
105
+ }
106
+ // Linear: a diagonal across a 100 px box at the requested position.
107
+ const defaultOrigin = origin || [position.x - 50, position.y - 50];
108
+ const defaultDestination = destination || [position.x + 50, position.y + 50];
83
109
  return `
84
110
  ${itemVarName}.${propertyName} = {
85
111
  gradient: {
86
112
  stops: [${stopsCode}],
87
- radial: ${type === 'radial'}
113
+ radial: false
88
114
  },
89
115
  origin: [${defaultOrigin[0]}, ${defaultOrigin[1]}],
90
116
  destination: [${defaultDestination[0]}, ${defaultDestination[1]}]
@@ -172,6 +198,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
172
198
  textDirection.resolved = baseDirection(createProps.content);
173
199
  }
174
200
  const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
201
+ const createFillOpacity = createProps.fillOpacity !== undefined;
175
202
  const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
176
203
  const params = {
177
204
  ...(omitXY ? {} : { x: position.x, y: position.y }),
@@ -337,6 +364,8 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
337
364
  code += emitDirectionCheck('item', textDirection);
338
365
  if (textTabular)
339
366
  code += emitTabularCheck('item');
367
+ if (createFillOpacity)
368
+ code += emitFillOpacityCheck('item');
340
369
  if (textStrokePos)
341
370
  code += emitStrokePositionCheck('item', textStrokePos);
342
371
  if (textFit)
@@ -374,7 +403,7 @@ const itemId = item.data.registryId;
374
403
  app.historyManager.saveState();
375
404
 
376
405
  // Return item info
377
- ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
406
+ ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
378
407
  ? `, ignoredProperties: ${JSON.stringify(ignored)}, warning: ${JSON.stringify(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
379
408
  + 'Check the spelling against this item type\'s documented properties.')}`
380
409
  : ''} });`;
@@ -398,7 +427,7 @@ const MODIFY_KNOWN_KEYS = new Set([
398
427
  ...NORMALIZE_PARAM_READS,
399
428
  'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
400
429
  'audioGain', 'volume', 'gain', 'bornAt', 'ttl', 'smoothing',
401
- 'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition',
430
+ 'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition', 'fillOpacity',
402
431
  ]);
403
432
  /** Spellings callers reach for, and the key the engine actually reads. */
404
433
  const MODIFY_DID_YOU_MEAN = {
@@ -419,6 +448,9 @@ const CREATE_KNOWN_KEYS = new Set([
419
448
  // Lifetimes, on ANY item: this generator applies them through app.setLifetime
420
449
  // (emitLifetime), whatever create() itself reads.
421
450
  'bornAt', 'ttl',
451
+ // A solid fill's own alpha (FxTool 12 STROKEFILL); confirmed per call by
452
+ // emitFillOpacityCheck, since an older studio ignores it.
453
+ 'fillOpacity',
422
454
  ]);
423
455
  /**
424
456
  * Load a font family before text is drawn with it, and say if it still is not there.
@@ -566,15 +598,61 @@ function wantsTabular(p) {
566
598
  const list = Array.isArray(f) ? f.map(String) : typeof f === 'string' ? [f] : f && typeof f === 'object' ? Object.keys(f).filter((k) => f[k]) : [];
567
599
  return list.some((x) => /\btnum\b/.test(x));
568
600
  }
601
+ /**
602
+ * Whether tabular figures CHANGED the layout is the engine's to measure: the
603
+ * request flag (data.tabularFigures) is set even when the font's digits are
604
+ * already one width, where the layout moves nothing (9 TABNUM: DejaVu Sans
605
+ * reported applied while every advance matched the control). A studio with
606
+ * app.tabularFiguresReport measures the item's own font, and only its answer
607
+ * is reported as applied. One without it can only say the flag was recorded:
608
+ * 'unverified', never true.
609
+ */
569
610
  function emitTabularCheck(itemExpr) {
570
611
  return `
571
612
  const __tabular = (function(it) {
572
613
  if (!it || it.className !== 'PointText') return { applied: false, note: 'tabular figures apply to text items only.' };
614
+ if (typeof app.tabularFiguresReport === 'function') {
615
+ let r = null;
616
+ try { r = app.tabularFiguresReport(it); } catch (e) { return { applied: 'unverified', note: 'the studio could not measure tabular figures on this text (' + ((e && e.message) || e) + ').' }; }
617
+ if (r && typeof r === 'object') {
618
+ const out = { applied: r.applied === true };
619
+ if (typeof r.steadyWidth === 'boolean') out.steadyWidth = r.steadyWidth;
620
+ if (r.reason) out.reason = r.reason;
621
+ if (r.note) out.note = r.note;
622
+ else if (r.reason === 'rtl') out.note = 'right-to-left text keeps proportional digits.';
623
+ else if (r.reason === 'not-requested') out.note = 'the studio did not record the request, so digits keep their proportional widths.';
624
+ if (typeof r.spread === 'number') out.digitSpread = r.spread;
625
+ if (r.width && typeof r.width === 'object') out.width = r.width;
626
+ return out;
627
+ }
628
+ return { applied: 'unverified', note: 'the studio returned no tabular-figures report for this text.' };
629
+ }
573
630
  return it.data && it.data.tabularFigures === true
574
- ? { applied: true }
631
+ ? { applied: 'unverified', note: 'this studio recorded tabular figures but cannot measure whether they change the layout (it has no tabularFiguresReport): with a font whose digits are already one width they change nothing.' }
575
632
  : { applied: false, note: 'this studio does not lay out tabular figures (it needs an engine with them): digits keep their proportional widths. A monospaced font is the fallback.' };
576
633
  })(${itemExpr});`;
577
634
  }
635
+ /**
636
+ * fillOpacity (FxTool 12 STROKEFILL): a solid fill's own alpha, apart from the
637
+ * item's opacity and its stroke, so an outline-only type is fillOpacity: 0.
638
+ * The engine records it on data.fillOpacity and re-applies it when a later
639
+ * modify changes the fill colour. A studio without it ignores the key, so the
640
+ * reply is read from the item: the recorded value and the fill's alpha.
641
+ */
642
+ function emitFillOpacityCheck(itemExpr) {
643
+ return `
644
+ const __fillOpacity = (function(it) {
645
+ if (!it) return { applied: false, note: 'no item to check.' };
646
+ const v = it.data && it.data.fillOpacity;
647
+ if (typeof v !== 'number') return { applied: false, note: 'this studio does not read fillOpacity (it needs an engine with it): the fill keeps its own alpha. opacity fades the whole item, stroke included; an rgba() fill sets the fill alone.' };
648
+ const c = it.fillColor;
649
+ if (!c) return { applied: true, value: v, note: 'the item has no fill now, so there is nothing to see; a fill colour set later with modify_item takes this alpha.' };
650
+ if (c.gradient) return { applied: false, value: v, note: 'a gradient fill keeps its own stop colours: put the alpha in the stops (rgba) instead.' };
651
+ return (typeof c.alpha === 'number' && Math.abs(c.alpha - v) > 0.01)
652
+ ? { applied: false, value: v, note: 'the studio recorded fillOpacity but the fill is drawn at alpha ' + c.alpha + '.' }
653
+ : { applied: true, value: v };
654
+ })(${itemExpr});`;
655
+ }
578
656
  function takeStrokePosition(props, allowNull) {
579
657
  if (!('strokePosition' in props))
580
658
  return null;
@@ -853,6 +931,7 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
853
931
  delete restProperties.fit;
854
932
  const modDirection = takeDirection(restProperties);
855
933
  const modTabular = wantsTabular(restProperties) === true;
934
+ const modFillOpacity = restProperties.fillOpacity !== undefined;
856
935
  const modStrokePos = takeStrokePosition(restProperties, true);
857
936
  // modifyItem(id, changes), NOT select() + modify().
858
937
  //
@@ -992,8 +1071,8 @@ ${properties.content !== undefined ? `
992
1071
  const __ctItem = app.getItemById('${itemId}');
993
1072
  const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyframes))
994
1073
  ? __ctItem.data.keyframes.filter(function(k) { return k && k.properties && Object.prototype.hasOwnProperty.call(k.properties, 'content'); }).length : 0;` : ''}
995
- ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
996
- return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
1074
+ ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modFillOpacity ? emitFillOpacityCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
1075
+ return { success: true, itemId: '${itemId}'${properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : ''}${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
997
1076
  // Wrapped, because the body now RETURNS early on a miss rather than falling
998
1077
  // through to an unconditional success. Async only when a font must load
999
1078
  // before modifyItem re-measures the text.
@@ -1035,6 +1114,15 @@ function generateDeleteItemCode(itemId) {
1035
1114
  /**
1036
1115
  * Template for adding relations
1037
1116
  */
1117
+ /**
1118
+ * A JS expression: the studio's own reason for refusing a relation of `typeJs`,
1119
+ * or null. addRelation answers a bare false; the WHY (B119: "relation
1120
+ * 'spring_follow' is not loaded yet…") is recorded with app.noteWarning, which
1121
+ * reaches the reply only as a governor warning. Read here without draining it.
1122
+ */
1123
+ export function relationRefusalReasonJs(typeJs) {
1124
+ return `(function(){ var ws = (app && app._engineWarnings) || []; for (var i = ws.length - 1; i >= 0; i--) { var w = ws[i]; if (w && w.relation === ${typeJs} && typeof w.message === 'string') return w.message; } return null; })()`;
1125
+ }
1038
1126
  function generateAddRelationCode(sourceId, targetId, relationType, params) {
1039
1127
  // AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
1040
1128
  // stores a relation to an id it cannot resolve, returns true, and says "it
@@ -1055,13 +1143,54 @@ function generateAddRelationCode(sourceId, targetId, relationType, params) {
1055
1143
  }
1056
1144
  const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
1057
1145
  if (!success) {
1058
- return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}.' };
1146
+ const __why = ${relationRefusalReasonJs(JSON.stringify(relationType))};
1147
+ return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}' + (__why ? ': ' + __why : '.') };
1059
1148
  }
1060
1149
  app.historyManager.saveState();
1061
1150
  return { success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' };
1062
1151
  })();
1063
1152
  `.trim();
1064
1153
  }
1154
+ /**
1155
+ * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
1156
+ * MAX_BURST_COUNT, B144). Studios before that change capped at
1157
+ * EMITTER_LEGACY_MAX_PER_BURST, in silence. Which one a studio has is read
1158
+ * back from the spec it stored (generateEmitter), not assumed.
1159
+ */
1160
+ export const EMITTER_MAX_PER_BURST = 10000;
1161
+ /** The cap of studios before B144 (normalizeLook clamped at 2000 without a word). */
1162
+ export const EMITTER_LEGACY_MAX_PER_BURST = 2000;
1163
+ /**
1164
+ * Every count in an emitter spec (the spec's own and each burst's) that some
1165
+ * studio may cap: above the legacy cap. `index` is null for the spec's own
1166
+ * count (each burst without its own inherits it).
1167
+ */
1168
+ export function emitterCountsToCheck(spec) {
1169
+ const out = [];
1170
+ if (!spec || typeof spec !== 'object')
1171
+ return out;
1172
+ const capped = (v) => typeof v === 'number' && Number.isFinite(v) && Math.round(v) > EMITTER_LEGACY_MAX_PER_BURST;
1173
+ if (capped(spec.count))
1174
+ out.push({ where: 'count', index: null, asked: Math.round(spec.count) });
1175
+ if (Array.isArray(spec.bursts)) {
1176
+ spec.bursts.forEach((b, i) => {
1177
+ const c = b && typeof b === 'object' ? b.count : undefined;
1178
+ if (capped(c))
1179
+ out.push({ where: `bursts[${i}].count`, index: i, asked: Math.round(c) });
1180
+ });
1181
+ }
1182
+ return out;
1183
+ }
1184
+ /**
1185
+ * What can be said about the counts WITHOUT the studio's answer: above the
1186
+ * current cap is capped on every studio; between the two caps depends on the
1187
+ * studio. Used when the stored spec cannot be read back.
1188
+ */
1189
+ export function emitterCountWarnings(spec) {
1190
+ return emitterCountsToCheck(spec).map(({ where, asked }) => asked > EMITTER_MAX_PER_BURST
1191
+ ? `${where} ${asked} is over the ${EMITTER_MAX_PER_BURST}-particle cap per burst of the current studio (${EMITTER_LEGACY_MAX_PER_BURST} on older studios), so it draws at most ${EMITTER_MAX_PER_BURST}. Split it into bursts at the same t for more.`
1192
+ : `${where} ${asked} is over the ${EMITTER_LEGACY_MAX_PER_BURST}-particle cap per burst of studios before the ${EMITTER_MAX_PER_BURST} cap, and this studio did not report what it drew: on an older studio it draws ${EMITTER_LEGACY_MAX_PER_BURST}. Split it into bursts at the same t to be sure.`);
1193
+ }
1065
1194
  /**
1066
1195
  * moves_along_path, from the parameters a caller actually writes.
1067
1196
  *
@@ -1132,7 +1261,27 @@ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1132
1261
  if (len > 0) params.speed = len / (150 * ${duration});
1133
1262
  if (params.closed === undefined) params.closed = false;
1134
1263
  }` : ''}
1135
- ${hasDelay ? `if (!params.window) params.window = { start: ${delay} };` : ''}
1264
+ // AN EQUATION ROUTE WITH A DURATION (queue item 22, ORBITDUR). There are no
1265
+ // points here to turn a duration into speed, so it was dropped and the curve
1266
+ // ran at the default speed. A studio whose rule times an equation (FxTool
1267
+ // claude/engine-core-queue: its equation param says duration times it) gets
1268
+ // duration, and delay on the same playhead clock instead of a window (a
1269
+ // window would start the timed ride part-way through). An older studio hands
1270
+ // a timed equation to the arm that cannot read one and loses the curve, so it
1271
+ // keeps today's untimed ride and is told.
1272
+ const __warnings = [];
1273
+ let __eqTimed = false;
1274
+ ${hasDuration ? `if (params.equation && !hasPoints) {
1275
+ const eqDesc = rule && rule.params && rule.params.equation && rule.params.equation.description;
1276
+ if (typeof eqDesc === 'string' && /duration/.test(eqDesc)) {
1277
+ __eqTimed = true;
1278
+ params.duration = ${duration};
1279
+ ${hasDelay ? `params.delay = ${delay};` : ''}
1280
+ } else {
1281
+ __warnings.push('duration ${duration} was not applied: this studio cannot time an equation route (a timed equation loses its curve there), so the route stays untimed at speed ' + (params.speed !== undefined ? params.speed : 1) + ' (1 = 150 px/s). Update the studio, or pass params.path as points.');
1282
+ }
1283
+ }` : ''}
1284
+ ${hasDelay ? `if (!__eqTimed && !params.window) params.window = { start: ${delay} };` : ''}
1136
1285
  // Before its window opens the relation does not run, so the item stays where
1137
1286
  // it was left — the retest saw it parked at the path END. Put it at the start.
1138
1287
  if (Array.isArray(params.path) && params.path.length) {
@@ -1145,7 +1294,8 @@ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1145
1294
  app.historyManager.saveState();
1146
1295
  return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'sampled-points',
1147
1296
  points: Array.isArray(params.path) ? params.path.length : 0, speed: params.speed, closed: params.closed,
1148
- ${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window };
1297
+ ${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window,
1298
+ ...(__eqTimed ? { duration: params.duration } : {}), ...(__warnings.length ? { warnings: __warnings } : {}) };
1149
1299
  })();`.trim();
1150
1300
  }
1151
1301
  /**
@@ -1327,7 +1477,35 @@ if (!_target) {
1327
1477
  return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
1328
1478
  }`.trim();
1329
1479
  }
1330
- function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false) {
1480
+ /**
1481
+ * Text keys a studio may or may not animate (queue item 2, FxTool
1482
+ * claude/engine-core-queue): fontStyle, textAlign, lineHeight and letterSpacing
1483
+ * apply as keyframes on the current studio, which lists them (textAlign as
1484
+ * justification, lineHeight as leading) in listAnimatableProperties. An older
1485
+ * studio stores the keys and draws nothing, so the check runs IN THE PAGE
1486
+ * against that list, and only the keys it does not name are reported.
1487
+ */
1488
+ const TEXT_KEY_ENGINE_NAMES = {
1489
+ fontStyle: ['fontStyle'],
1490
+ textAlign: ['textAlign', 'justification'],
1491
+ lineHeight: ['lineHeight', 'leading'],
1492
+ letterSpacing: ['letterSpacing'],
1493
+ };
1494
+ /** A JS expression (run in the page) → the keys of `keys` this studio does not animate. '' when none are text keys. */
1495
+ function unanimatedTextKeysJs(keys) {
1496
+ const spec = [...new Set(keys)].filter((k) => k in TEXT_KEY_ENGINE_NAMES).map((k) => ({ key: k, names: TEXT_KEY_ENGINE_NAMES[k] }));
1497
+ if (!spec.length)
1498
+ return '';
1499
+ return `(function() {
1500
+ let __l = null;
1501
+ try { __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null; } catch (_) { __l = null; }
1502
+ const __names = ((__l && __l.properties) || []).map(function(p) { return p && p.name; });
1503
+ return ${JSON.stringify(spec)}.filter(function(s) { return !s.names.some(function(n) { return __names.indexOf(n) >= 0; }); }).map(function(s) { return s.key; });
1504
+ })()`;
1505
+ }
1506
+ /** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
1507
+ const UNANIMATED_TEXT_KEYS_SENTENCE = `' is not an animatable property in this studio (app.listAnimatableProperties does not list it), so it had no effect. The current studio animates fontStyle, textAlign, lineHeight and letterSpacing; here, set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.'`;
1508
+ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false, easingAt) {
1331
1509
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
1332
1510
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
1333
1511
  const opts = { duration: calculatedDuration, loop };
@@ -1343,6 +1521,11 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1343
1521
  // 2475333b). An engine without merge ignores the option and replaces.
1344
1522
  if (append)
1345
1523
  opts.mode = 'merge';
1524
+ // EASEPLACE (queue item 5): which side of a key its easing shapes. The
1525
+ // current studio reads (and removes) the option; an older one leaves it on
1526
+ // the options object, which is how the result can say it was not applied.
1527
+ if (easingAt !== undefined)
1528
+ opts.easingAt = easingAt;
1346
1529
  // A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
1347
1530
  // the audio item's sentinel, and the mix reads one static audioGain per clip,
1348
1531
  // so {audioGain: 0→1} answered success, duration 15, and exported at a flat
@@ -1394,12 +1577,16 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1394
1577
  const loopNote = loop
1395
1578
  ? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
1396
1579
  : '';
1397
- const easingNote = first?.easing && first.easing !== 'linear'
1398
- ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
1580
+ const easingNote = easingAt !== 'departure' && first?.easing && first.easing !== 'linear'
1581
+ ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect by default: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to, or pass easingAt: 'departure' to read every easing as the move LEAVING its key (current studio).`)}`
1399
1582
  : '';
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.`)}`;
1583
+ const textKeysJs = unanimatedTextKeysJs(keyframes.flatMap((k) => Object.keys(k.properties ?? {})));
1584
+ const textKeyCheck = textKeysJs ? `
1585
+ const __ignored = ${textKeysJs};
1586
+ if (__ignored.length) __warn.push(__ignored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE});` : '';
1587
+ const easingAtCheck = easingAt !== undefined ? `
1588
+ if (__opts && Object.prototype.hasOwnProperty.call(__opts, 'easingAt')) __warn.push('easingAt ' + ${JSON.stringify(JSON.stringify(easingAt))} + ' was not applied: this studio predates it, so every easing was read as the move ARRIVING at its key. Put each easing on the key the move goes to instead.');` : '';
1589
+ const runtimeWarning = `${textKeysJs ? ', ...(__ignored.length ? { ignoredProperties: __ignored } : {})' : ''}, ...(__warn.length ? { warning: __warn.join(' ') } : {})`;
1403
1590
  const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
1404
1591
  return `
1405
1592
  // Apply keyframe animation to ${itemId}
@@ -1410,7 +1597,9 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1410
1597
  // merge support — an older engine takes the option and replaces anyway.
1411
1598
  const __ki = _target && (_target.item || _target);
1412
1599
  const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1413
- app.addAnimation(${addAnimTarget}, ${keyframesJson}, ${JSON.stringify(opts)});
1600
+ const __opts = ${JSON.stringify(opts)};
1601
+ app.addAnimation(${addAnimTarget}, ${keyframesJson}, __opts);
1602
+ const __warn = [];${easingAtCheck}${textKeyCheck}
1414
1603
  const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
1415
1604
  const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
1416
1605
  // Property gaps across the merged track (the same check a single call makes
@@ -1423,7 +1612,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
1423
1612
  ? { appended: true, previousKeys: __prev.length, keys: __now.length }
1424
1613
  : { appended: false, replaced: true, previousKeys: __prev.length, warning: 'append was asked for, but this studio replaced the existing track (' + __prev.length + ' key(s)) instead of adding to it: it has no merge mode. Put every key in one keyframe_animate call.' })`
1425
1614
  : `{ replaced: true, previousKeys: __prev.length, note: 'the item already had a keyframe track (' + __prev.length + ' key(s)); this call replaced it. To add keys to it instead, pass append: true.' }`};
1426
- return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${letterSpacingWarning} };
1615
+ return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${runtimeWarning} };
1427
1616
  })();
1428
1617
  `.trim();
1429
1618
  }
@@ -2730,7 +2919,9 @@ export class PinePaperCodeGenerator {
2730
2919
  // silence; refuse before anything is written. setStepTiming ships in the
2731
2920
  // same change, so it is the probe.
2732
2921
  const holds = validated.keyframes.some((k) => k.interpolation === 'hold' || k.hold === true);
2733
- const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2922
+ const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits,
2923
+ // mode: 'merge' is the studio's name for append (B147).
2924
+ validated.append === true || validated.mode === 'merge', validated.easingAt);
2734
2925
  if (!holds)
2735
2926
  return code;
2736
2927
  return `(async function() {
@@ -3370,8 +3561,15 @@ if (typeof _gen === 'function') {
3370
3561
  const targetId = nameToId['${target}'];
3371
3562
  if (sourceId && targetId) {
3372
3563
  const params = ${paramsStr};
3373
- app.addRelation(sourceId, targetId, '${type}', params);
3374
- results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3564
+ // A refused relation is not listed as made (owner rule 1).
3565
+ if (app.addRelation(sourceId, targetId, '${type}', params)) {
3566
+ results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3567
+ } else {
3568
+ const __why = ${relationRefusalReasonJs(JSON.stringify(type))};
3569
+ (results.relationsRefused = results.relationsRefused || []).push({ source: '${source}', target: '${target}', type: '${type}', reason: __why || 'the studio refused it' });
3570
+ results.success = false;
3571
+ results.error = 'the studio refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';
3572
+ }
3375
3573
  }
3376
3574
  })();
3377
3575
  `);
@@ -4298,6 +4496,15 @@ const target = app.getItemById(targetId);
4298
4496
  if (!source) throw new Error('Source not found: ' + sourceId);
4299
4497
  if (!target) throw new Error('Target not found: ' + targetId);
4300
4498
  const relationId = app.addRelation(source, target, '${op.relationType}', ${relOpts});
4499
+ // NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false
4500
+ // when it refuses — spring_follow "not loaded yet" — and this op returned
4501
+ // { relationId: false } with nothing marking it failed, so the batch said
4502
+ // success over a relation that does not exist.
4503
+ if (!relationId) {
4504
+ const __why = ${relationRefusalReasonJs(JSON.stringify(op.relationType))};
4505
+ return { success: false, relationId: false, sourceId, targetId, relationType: ${JSON.stringify(op.relationType)},
4506
+ error: 'the studio refused the ' + ${JSON.stringify(op.relationType)} + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };
4507
+ }
4301
4508
  return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4302
4509
  `;
4303
4510
  case 'delete':
@@ -4375,7 +4582,10 @@ return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)}
4375
4582
  : (op.duration || op.properties?.duration || (kfSource.length ? Math.max(...kfSource.map((k) => Number(k.time) || 0)) : 5));
4376
4583
  const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? op.properties?.loop ?? false);
4377
4584
  // Same contract as pinepaper_keyframe_animate: replace unless append.
4378
- const kfAppend = op.append === true || op.properties?.append === true;
4585
+ // mode: 'merge' is the studio's spelling (its keyframe_track_replaced
4586
+ // warning names it), so it means append here too (B147).
4587
+ const kfAppend = op.append === true || op.properties?.append === true
4588
+ || op.mode === 'merge' || op.properties?.mode === 'merge';
4379
4589
  // Batch ops are passthrough, so these arrive unvalidated: numbers only, and timeUnits from its enum.
4380
4590
  const kfExtra = [];
4381
4591
  for (const key of ['timeOffset', 'clipInPoint', 'clipOutPoint']) {
@@ -4387,9 +4597,9 @@ return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)}
4387
4597
  if (kfUnits === 'seconds' || kfUnits === 'ms')
4388
4598
  kfExtra.push(`timeUnits: '${kfUnits}'`);
4389
4599
  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.`)}`;
4600
+ const kfTextKeysJs = unanimatedTextKeysJs((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})));
4601
+ const kfTextCheck = kfTextKeysJs ? `\nconst _kfIgnored = ${kfTextKeysJs};` : '';
4602
+ const kfIgnored = kfTextKeysJs ? `, ...(_kfIgnored.length ? { ignoredProperties: _kfIgnored, warning: _kfIgnored.join(', ') + ${UNANIMATED_TEXT_KEYS_SENTENCE} } : {})` : '';
4393
4603
  return `
4394
4604
  const targetId = ${kfItemRef};
4395
4605
  const _target = (app.getItemById && app.getItemById(targetId))
@@ -4399,7 +4609,7 @@ if (!_target) {
4399
4609
  }
4400
4610
  const _kfItem = _target.item || _target;
4401
4611
  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} });
4612
+ app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ", mode: 'merge'" : ''}${kfExtraStr} });${kfTextCheck}
4403
4613
  return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ', append: true' : ''}${kfAppend ? '' : `, ...(_prevKeys ? { track: { replaced: true, previousKeys: _prevKeys, note: 'the item already had a keyframe track; this op replaced it. To add keys to it instead, pass append: true.' } } : {})`}${kfIgnored} };
4404
4614
  `;
4405
4615
  }
@@ -5573,7 +5783,30 @@ ${stillTime !== undefined ? `
5573
5783
 
5574
5784
  if (result && result.success) Object.assign(result, fidelity(format));
5575
5785
  if (result && result.success && __fit) result.platformFit = __fit;
5576
- // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5786
+ ${usesCanvasSize ? '' : ` // A PRESET SIZE IS NOT THE CANVAS SIZE, AND THAT IS SAID (21 PLATFORMOVERRIDE).
5787
+ // A named platform renders the FILE at the preset's size; the canvas is not
5788
+ // resized, so the scene keeps the canvas's layout, scaled to fill the file.
5789
+ // A 640x360 canvas under youtube came back 1920x1080 with nothing said. The
5790
+ // size the route reports is compared with the canvas; the current studio's
5791
+ // video report warns about an upscale too, but as plain text this reply does
5792
+ // not pass on, so it is said here for every studio.
5793
+ // Not said for camera framing (the camera chooses that frame) or an
5794
+ // unbounded canvas (its stills crop to the artwork, said on their own), and
5795
+ // a scale the caller asked for is theirs, not the preset's.
5796
+ if (result && result.success && framing !== 'camera' && __cv && !__cv.unbounded && __cv.width > 0 && __cv.height > 0
5797
+ && result.dimensions && typeof result.dimensions === 'object' && result.dimensions.width > 0 && result.dimensions.height > 0) {
5798
+ const __fw = result.dimensions.width, __fh = result.dimensions.height;
5799
+ const __us = ${scale !== undefined ? scale : 1};
5800
+ if (Math.abs(__fw - __cv.width * __us) > 2 || Math.abs(__fh - __cv.height * __us) > 2) {
5801
+ const __k = Math.round((__fw / __cv.width) * 1000) / 1000;
5802
+ result.platformSize = { preset: __presetBox.width + 'x' + __presetBox.height, canvas: __cv.width + 'x' + __cv.height, file: __fw + 'x' + __fh, scale: __k, canvasResized: false };
5803
+ result.fidelity = result.fidelity || { warnings: [] };
5804
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'platform_size_differs_from_canvas',
5805
+ message: 'the ' + platform + ' preset made the file ' + __fw + 'x' + __fh + ' while the canvas is ' + __cv.width + 'x' + __cv.height + ': the scene is drawn ' + (__k > 1 ? 'larger' : 'smaller') + ' (x' + __k + ') with the canvas\\'s layout, and the canvas itself is unchanged. For a file at the canvas size, export with platform: \\'auto\\'; for a scene laid out at ' + __fw + 'x' + __fh + ', resize the canvas (pinepaper_set_canvas_size) and re-lay it.' }]);
5806
+ if (result.fidelity.note) delete result.fidelity.note;
5807
+ }
5808
+ }
5809
+ `} // A LANDSCAPE CANVAS FOR A PORTRAIT PLATFORM (or the reverse) is not an
5577
5810
  // aspect nuance but the wrong deliverable: tiktok on a 16:9 canvas came out
5578
5811
  // 1920x1080 with only a note (round 10, 10.7). Said where a caller looks.
5579
5812
  if (result && result.success && __fit) {
@@ -7818,7 +8051,17 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7818
8051
  `.trim();
7819
8052
  }
7820
8053
  generateImportImage(input) {
7821
- const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize } = input;
8054
+ const { url, position, maxWidth, maxHeight, mask, smoothing, maxEdge, nativeSize, shadowColor, shadowBlur, shadowOffset } = input;
8055
+ // IMPORTSHADOW: the shadow keys, applied through the engine's own modify
8056
+ // door once the image is placed (and masked) — the route modify_item takes.
8057
+ const shadow = {};
8058
+ if (shadowColor !== undefined)
8059
+ shadow.shadowColor = shadowColor;
8060
+ if (shadowBlur !== undefined)
8061
+ shadow.shadowBlur = shadowBlur;
8062
+ if (shadowOffset !== undefined)
8063
+ shadow.shadowOffset = shadowOffset;
8064
+ const hasShadow = Object.keys(shadow).length > 0;
7822
8065
  // Each fragment used to be written with a LEADING comma, as if it followed
7823
8066
  // an entry that no longer exists — so the first one opened the object with
7824
8067
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -7916,6 +8159,16 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7916
8159
  }
7917
8160
  if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
7918
8161
  placed = masked;
8162
+ ` : ''}${hasShadow ? ` // The shadow goes on what is DRAWN, after a mask has replaced the raster.
8163
+ let __shadow = null;
8164
+ {
8165
+ const __want = ${JSON.stringify(shadow)};
8166
+ let __ok = false;
8167
+ try { __ok = app.modifyItem(itemId, __want) !== false; } catch (_) { __ok = false; }
8168
+ __shadow = __ok
8169
+ ? { applied: true, shadowColor: placed.shadowColor ? String(placed.shadowColor.toCSS ? placed.shadowColor.toCSS(true) : placed.shadowColor) : null, shadowBlur: placed.shadowBlur }
8170
+ : { applied: false, warning: 'the image was imported but its shadow was not applied — set it with pinepaper_modify_item on ' + itemId + '.' };
8171
+ }
7919
8172
  ` : ''} const b = placed.bounds;
7920
8173
  if (!b || !(b.width > 0) || !(b.height > 0)) {
7921
8174
  return { success: false, itemId: itemId, error: 'the image was placed with no size (' + (b ? b.width + 'x' + b.height : 'no bounds') + ') — it may not have loaded. Nothing visible was drawn.' };
@@ -7924,7 +8177,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7924
8177
  success: true,
7925
8178
  itemId: itemId,
7926
8179
  message: 'Image imported and placed on canvas.',
7927
- bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''},
8180
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
7928
8181
  // Stored smaller than its source (the studio's cap): said, with the
7929
8182
  // way to keep every pixel. The studio reports native size where it can.
7930
8183
  ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)
@@ -11043,7 +11296,9 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11043
11296
  app.create(op.type, { id: op.id, x: op.x, y: op.y, width: op.width, height: op.height });
11044
11297
  itemIds.push(op.id);
11045
11298
  } else if (op.op === 'addRelation') {
11046
- app.addRelation(op.from, op.to, op.relation, op.params || {});
11299
+ if (!app.addRelation(op.from, op.to, op.relation, op.params || {})) {
11300
+ errors.push({ op: op.op, id: op.from + '->' + op.to, error: 'the studio refused the ' + op.relation + ' relation' });
11301
+ }
11047
11302
  }
11048
11303
  } catch (e) {
11049
11304
  errors.push({ op: op.op, id: op.id || (op.from + '->' + op.to), error: e && e.message });
@@ -11592,9 +11847,41 @@ ${guard}
11592
11847
  const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
11593
11848
  ${authoringFrameJs('__fr')}
11594
11849
  const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
11595
- const __actor = { x: __it.position.x, y: __it.position.y,
11596
- w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
11597
- sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked };
11850
+ // THE ACTOR AT THE FIRST BEAT, not where the playhead left it (B154 /
11851
+ // HOOKCLOCK3). Live values of a keyframed item depend on the playhead, so a
11852
+ // second call anchored at +0 / +57 / +22 / +43 px from session to session
11853
+ // and multiplied scales the first track had applied. app.poseAt (FxTool,
11854
+ // claude/engine-core-queue) answers at a track time; older studios fall back
11855
+ // to the live read.
11856
+ const __at0 = ${S(Math.min(...input.beats.map((b) => Number(b && b.at) || 0)))};
11857
+ // A track that already keys the beat properties defines the pose before the
11858
+ // first beat; the B109 hold key at 0 would overwrite its key there. A track
11859
+ // of other properties (a colour) still needs the hold key.
11860
+ const __beatProps = ['x', 'y', 'scaleX', 'scaleY', 'scale', 'rotation', 'opacity', 'position'];
11861
+ const __hasTrack = !!(__it.data && Array.isArray(__it.data.keyframes) && __it.data.keyframes.some(function (k) {
11862
+ return Object.keys((k && k.properties) || {}).some(function (p) { return __beatProps.indexOf(p) >= 0; });
11863
+ }));
11864
+ let __pose = null;
11865
+ let __poseErr = null;
11866
+ if (typeof app.poseAt === 'function') {
11867
+ try { __pose = app.poseAt(__it, __at0); } catch (e) { __poseErr = (e && e.message) || String(e); }
11868
+ if (__pose && !(isFinite(__pose.x) && isFinite(__pose.y) && __pose.width > 0 && __pose.height > 0)) __pose = null;
11869
+ }
11870
+ const __anchorWarnings = [];
11871
+ if (!__pose && __hasTrack) {
11872
+ __anchorWarnings.push((__poseErr ? 'app.poseAt failed (' + __poseErr + ')' : 'this studio has no app.poseAt')
11873
+ + ', so the beats start from the actor\\'s LIVE transform, which on a keyframed actor is wherever the playhead left it: the first move can jump. Update the studio, or seek to ' + __at0 + ' s before choreographing.');
11874
+ }
11875
+ if (!__pose && __it.className === 'Raster' && ${S(input.beats.some((b) => b && b.verb === 'roll'))}) {
11876
+ __anchorWarnings.push((__poseErr ? 'app.poseAt failed' : 'this studio has no app.poseAt')
11877
+ + ', so the roll turns on the picture\\'s half-box, not its drawn radius: a picture with a transparent margin slides as it rolls.');
11878
+ }
11879
+ const __actor = __pose
11880
+ ? { x: __pose.x, y: __pose.y, w: __pose.width, h: __pose.height, sx: __pose.scaleX || 1, sy: __pose.scaleY || 1, rot: isFinite(__pose.rotation) ? __pose.rotation : (__it.rotation || 0) + __baked, hasTrack: __hasTrack,
11881
+ rollRadius: __pose.drawnHeight ? __pose.drawnHeight / 2 : undefined }
11882
+ : { x: __it.position.x, y: __it.position.y,
11883
+ w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
11884
+ sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked, hasTrack: __hasTrack };
11598
11885
  const __r = (${compileChoreography.toString()})(__actor, __frame, ${S(input.beats)});
11599
11886
  if (__r.error) { return { success: false, itemId: ${id}, error: __r.error }; }
11600
11887
  const __prev = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes : [];
@@ -11603,7 +11890,7 @@ ${guard}
11603
11890
  const __other = __prev.reduce(function (acc, k) { Object.keys((k && k.properties) || {}).forEach(function (p) { if (__ours.indexOf(p) < 0 && acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
11604
11891
  app.addAnimation(${id}, __r.keyframes, { duration: Math.max(__r.end, __prev.reduce(function (m, k) { return Math.max(m, (k && k.time) || 0); }, 0)), loop: false, mode: 'merge' });
11605
11892
  const __now = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes.map(function (k) { return k && k.time; }) : [];
11606
- const __warnings = (__r.warnings || []).slice();
11893
+ const __warnings = __anchorWarnings.concat(__r.warnings || []);
11607
11894
  if (__prevTimes.length && !__prevTimes.every(function (t) { return __now.indexOf(t) >= 0; })) {
11608
11895
  __warnings.push('this studio replaced the existing track of the actor (' + __prevTimes.length + ' key(s)) instead of merging: put the other keys in after choreographing.');
11609
11896
  }
@@ -11966,27 +12253,49 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
11966
12253
  /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
11967
12254
  generateEmitter(input) {
11968
12255
  const S = (v) => JSON.stringify(v);
12256
+ // A COUNT OVER THE CAP IS SAID (B144). The cap is 10000 a burst on the
12257
+ // current studio and was 2000 before it, clamped in silence (2300 drew
12258
+ // ~1940). Which applies is read from the spec the studio STORED — its
12259
+ // normalised count is what it draws — so the reply names the number
12260
+ // actually drawn on whichever studio ran it. Only when nothing can be read
12261
+ // back does it fall back to what is known without the studio.
12262
+ const checks = emitterCountsToCheck(input.spec);
12263
+ const fallback = emitterCountWarnings(input.spec);
12264
+ const capFn = checks.length ? `
12265
+ function __capWarnings(stored) {
12266
+ const checks = ${S(checks)};
12267
+ if (!stored || typeof stored !== 'object') return ${S(fallback)};
12268
+ const out = [];
12269
+ for (const c of checks) {
12270
+ const b = c.index === null ? null : (Array.isArray(stored.bursts) ? stored.bursts[c.index] : null);
12271
+ const drawn = b && typeof b.count === 'number' ? b.count : stored.count;
12272
+ if (typeof drawn !== 'number') return ${S(fallback)};
12273
+ if (drawn < c.asked) out.push(c.where + ' ' + c.asked + ' is over this studio\\'s per-burst cap, so it draws ' + drawn + '. Split it into bursts at the same t for more.');
12274
+ }
12275
+ return out;
12276
+ }` : '';
12277
+ const capJs = (stored) => checks.length ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(__capWarnings(${stored})))` : '';
11969
12278
  if (input.action === 'set') {
11970
12279
  return `
11971
12280
  // Emitter: change a spec
11972
12281
  (function() {
11973
- if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
12282
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }${capFn}
11974
12283
  const r = app.setEmitter(${S(input.itemId)}, ${S(input.spec)});
11975
12284
  if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the emitter change was refused' }; }
11976
- return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec };
12285
+ return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec${capJs('r.spec')} };
11977
12286
  })();`.trim();
11978
12287
  }
11979
12288
  return `
11980
12289
  // Emitter: create
11981
12290
  (function() {
11982
- if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
12291
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }${capFn}
11983
12292
  const it = app.create('emitter', ${S(input.spec)});
11984
12293
  // A bad spec comes back null with an emitter-invalid warning; the reason
11985
12294
  // reaches this reply through the governor's warnings.
11986
12295
  if (!it) { return { success: false, error: 'the emitter spec was refused' }; }
11987
12296
  const id = it.data && (it.data.registryId || it.data.id);
11988
12297
  if (!id) { return { success: false, error: 'the emitter was made but not registered — no usable id' }; }
11989
- return { success: true, action: 'create', itemId: id };
12298
+ return { success: true, action: 'create', itemId: id${capJs('it.data && it.data.emitter')} };
11990
12299
  })();`.trim();
11991
12300
  }
11992
12301
  /** pinepaper_render_hook (D29): drawing saved as source, re-installed on load. */
@@ -12982,7 +13291,7 @@ ${needWorld}
12982
13291
  * already decided by a pure function, so nothing is left for the page to
12983
13292
  * work out.
12984
13293
  */
12985
- generateDesignCompose(scene, ops, style, sizeWarning) {
13294
+ generateDesignCompose(scene, ops, style, sizeWarning, report = {}) {
12986
13295
  const S = (v) => JSON.stringify(v);
12987
13296
  return `
12988
13297
  // Design: compose a ${style} scene — ${ops.length} items, laid out server-side
@@ -13012,7 +13321,7 @@ ${needWorld}
13012
13321
  // success is worse than a poster that says which four elements are missing.
13013
13322
  return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed,
13014
13323
  ...(canvasResized ? { canvasResized: canvasResized, note: 'the canvas was resized to the composition' + (${S(sizeWarning ?? '')} ? '' : '; pass width and height to compose at your canvas size') } : {}),
13015
- ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}) };
13324
+ ...(${S(sizeWarning ?? '')} ? { warning: ${S(sizeWarning ?? '')} } : {}), ...${S(report)} };
13016
13325
  })();`.trim();
13017
13326
  }
13018
13327
  /**