@pinepaper.studio/mcp-server 1.6.23 → 1.6.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) 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 +84 -5
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/SECURITY.md +46 -0
  11. package/dist/agent/types.d.ts +114 -106
  12. package/dist/agent/types.d.ts.map +1 -1
  13. package/dist/agent/types.js +1 -1
  14. package/dist/agent/types.js.map +1 -1
  15. package/dist/browser/puppeteer-controller.js +2 -2
  16. package/dist/browser/puppeteer-controller.js.map +1 -1
  17. package/dist/build-info.json +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +13 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/ontology/hints.d.ts.map +1 -1
  22. package/dist/ontology/hints.js +5 -1
  23. package/dist/ontology/hints.js.map +1 -1
  24. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  25. package/dist/ontology/upstream-vocabulary.js +1 -1
  26. package/dist/ontology/vocabulary.d.ts.map +1 -1
  27. package/dist/ontology/vocabulary.js +1 -0
  28. package/dist/ontology/vocabulary.js.map +1 -1
  29. package/dist/tools/batch-macros.d.ts +27 -0
  30. package/dist/tools/batch-macros.d.ts.map +1 -0
  31. package/dist/tools/batch-macros.js +157 -0
  32. package/dist/tools/batch-macros.js.map +1 -0
  33. package/dist/tools/choreograph.d.ts.map +1 -1
  34. package/dist/tools/choreograph.js +2 -0
  35. package/dist/tools/choreograph.js.map +1 -1
  36. package/dist/tools/compact-descriptions.js +3 -3
  37. package/dist/tools/compact-descriptions.js.map +1 -1
  38. package/dist/tools/definitions.d.ts.map +1 -1
  39. package/dist/tools/definitions.js +87 -5
  40. package/dist/tools/definitions.js.map +1 -1
  41. package/dist/tools/engine-lists.d.ts +2 -2
  42. package/dist/tools/engine-lists.js +2 -2
  43. package/dist/tools/engine-methods.d.ts.map +1 -1
  44. package/dist/tools/engine-methods.js +1 -0
  45. package/dist/tools/engine-methods.js.map +1 -1
  46. package/dist/tools/engine-surface.d.ts +1 -1
  47. package/dist/tools/engine-surface.d.ts.map +1 -1
  48. package/dist/tools/engine-surface.js +52 -6
  49. package/dist/tools/engine-surface.js.map +1 -1
  50. package/dist/tools/export-file-check.d.ts +31 -0
  51. package/dist/tools/export-file-check.d.ts.map +1 -0
  52. package/dist/tools/export-file-check.js +103 -0
  53. package/dist/tools/export-file-check.js.map +1 -0
  54. package/dist/tools/export-file-receipt.d.ts +56 -0
  55. package/dist/tools/export-file-receipt.d.ts.map +1 -0
  56. package/dist/tools/export-file-receipt.js +151 -0
  57. package/dist/tools/export-file-receipt.js.map +1 -0
  58. package/dist/tools/handlers/beats.d.ts +7 -44
  59. package/dist/tools/handlers/beats.d.ts.map +1 -1
  60. package/dist/tools/handlers/export.d.ts.map +1 -1
  61. package/dist/tools/handlers/export.js +31 -6
  62. package/dist/tools/handlers/export.js.map +1 -1
  63. package/dist/tools/handlers/generate.d.ts +41 -270
  64. package/dist/tools/handlers/generate.d.ts.map +1 -1
  65. package/dist/tools/handlers.d.ts +67 -1
  66. package/dist/tools/handlers.d.ts.map +1 -1
  67. package/dist/tools/handlers.js +304 -67
  68. package/dist/tools/handlers.js.map +1 -1
  69. package/dist/tools/lane.d.ts +34 -0
  70. package/dist/tools/lane.d.ts.map +1 -0
  71. package/dist/tools/lane.js +210 -0
  72. package/dist/tools/lane.js.map +1 -0
  73. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  74. package/dist/tools/minimal-descriptions.js +3 -2
  75. package/dist/tools/minimal-descriptions.js.map +1 -1
  76. package/dist/tools/nested-ignored.d.ts +16 -0
  77. package/dist/tools/nested-ignored.d.ts.map +1 -0
  78. package/dist/tools/nested-ignored.js +83 -0
  79. package/dist/tools/nested-ignored.js.map +1 -0
  80. package/dist/tools/shape-params.d.ts +2 -2
  81. package/dist/tools/shape-params.d.ts.map +1 -1
  82. package/dist/tools/shape-params.js +9 -2
  83. package/dist/tools/shape-params.js.map +1 -1
  84. package/dist/tools/stick-vocabulary.d.ts +4 -4
  85. package/dist/tools/stick-vocabulary.d.ts.map +1 -1
  86. package/dist/tools/stick-vocabulary.js +8 -4
  87. package/dist/tools/stick-vocabulary.js.map +1 -1
  88. package/dist/tools/timeline.d.ts +136 -0
  89. package/dist/tools/timeline.d.ts.map +1 -0
  90. package/dist/tools/timeline.js +1036 -0
  91. package/dist/tools/timeline.js.map +1 -0
  92. package/dist/tools/toolkits.d.ts +1 -1
  93. package/dist/tools/toolkits.d.ts.map +1 -1
  94. package/dist/tools/toolkits.js +12 -2
  95. package/dist/tools/toolkits.js.map +1 -1
  96. package/dist/types/code-generator.d.ts +30 -2
  97. package/dist/types/code-generator.d.ts.map +1 -1
  98. package/dist/types/code-generator.js +277 -53
  99. package/dist/types/code-generator.js.map +1 -1
  100. package/dist/types/schemas.d.ts +5276 -25048
  101. package/dist/types/schemas.d.ts.map +1 -1
  102. package/dist/types/schemas.js +78 -40
  103. package/dist/types/schemas.js.map +1 -1
  104. package/manifest.json +5 -1
  105. package/package.json +8 -7
@@ -5,11 +5,13 @@
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
7
  import { compileChoreography } from '../tools/choreograph.js';
8
+ import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
8
9
  import { normalizeSvgSize } from '../utils/svg-size.js';
9
10
  import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
10
11
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
11
12
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
12
13
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
14
+ import { expandBatchMacros } from '../tools/batch-macros.js';
13
15
  import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
14
16
  // Diagram types
15
17
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
@@ -26,7 +28,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
26
28
  // Custom relation/code types
27
29
  RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
28
30
  // Mask types
29
- ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
31
+ ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, PatternInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, KEYFRAME_EASINGS, EASING_DESCRIPTIONS, MaskTypeSchema, MaskPresetSchema, } from './schemas.js';
30
32
  import { OntologyCompiler } from '../ontology/ontology-compiler.js';
31
33
  // =============================================================================
32
34
  // HELPER FUNCTIONS
@@ -309,7 +311,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
309
311
  const textOnly = (k) => TEXT_STYLE_KEYS.includes(k) || k === 'fit' || k === 'textDirection' || k === 'dir'
310
312
  || TABULAR_KEYS.includes(k) || k === 'strokePosition';
311
313
  const ignored = Object.keys(properties).filter((k) => itemType === 'text'
312
- ? !CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k)
314
+ ? (!CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k))
315
+ || (k === 'letterSpacing' && letterSpacingCss(properties[k]) === undefined)
313
316
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
314
317
  // Build the code
315
318
  let code = `
@@ -734,13 +737,26 @@ function authoringFrameJs(name) {
734
737
  return (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || cs || { width: 1920, height: 1080 };
735
738
  })();`;
736
739
  }
737
- const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'textAlign', 'align', 'text'];
740
+ const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'letterSpacing', 'textAlign', 'align', 'text'];
741
+ /**
742
+ * letterSpacing as the studio draws it, or undefined if it cannot be. create()
743
+ * and modifyItem() do not read it (B108); the studio draws the item's own
744
+ * letterSpacing, a CSS length, as its keyframes set it, and a number is px.
745
+ */
746
+ function letterSpacingCss(v) {
747
+ if (typeof v === 'number' && Number.isFinite(v))
748
+ return `${v}px`;
749
+ if (typeof v === 'string' && /^-?\d*\.?\d+(px|em)?$/.test(v.trim()))
750
+ return /\d$/.test(v.trim()) ? `${v.trim()}px` : v.trim();
751
+ return undefined;
752
+ }
738
753
  function emitTextStyle(itemExpr, props) {
739
754
  const weight = props.fontWeight;
740
755
  const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
741
756
  const lh = typeof props.lineHeight === 'number' ? props.lineHeight : undefined;
742
757
  const leading = typeof props.leading === 'number' ? props.leading : undefined;
743
- if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined)
758
+ const ls = letterSpacingCss(props.letterSpacing);
759
+ if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined && ls === undefined)
744
760
  return '';
745
761
  return `
746
762
  (function(it) {
@@ -753,6 +769,7 @@ function emitTextStyle(itemExpr, props) {
753
769
  : ''}
754
770
  ${leading !== undefined ? `it.leading = ${leading};` : ''}
755
771
  ${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
772
+ ${ls !== undefined ? `it.letterSpacing = ${JSON.stringify(ls)};` : ''}
756
773
  const b1 = it.bounds;
757
774
  it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
758
775
  })(${itemExpr});`;
@@ -1059,6 +1076,8 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
1059
1076
  // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
1060
1077
  // raster answered success and left it 16 px; the engine reads `scale`. Same
1061
1078
  // report create_item gives, against the engine's modify path (generated).
1079
+ if (textStyle && letterSpacingCss(properties.letterSpacing) !== undefined)
1080
+ aliased.add('letterSpacing');
1062
1081
  const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
1063
1082
  const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
1064
1083
  + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
@@ -1191,6 +1210,61 @@ export function emitterCountWarnings(spec) {
1191
1210
  ? `${where} ${asked} is over the ${EMITTER_MAX_PER_BURST}-particle cap per burst of the current studio (${EMITTER_LEGACY_MAX_PER_BURST} on older studios), so it draws at most ${EMITTER_MAX_PER_BURST}. Split it into bursts at the same t for more.`
1192
1211
  : `${where} ${asked} is over the ${EMITTER_LEGACY_MAX_PER_BURST}-particle cap per burst of studios before the ${EMITTER_MAX_PER_BURST} cap, and this studio did not report what it drew: on an older studio it draws ${EMITTER_LEGACY_MAX_PER_BURST}. Split it into bursts at the same t to be sure.`);
1193
1212
  }
1213
+ /**
1214
+ * An emitter spec as the studio reads it, and what the reply says about it.
1215
+ *
1216
+ * The studio reads velocities and offsets as [x, y] pairs only and drops any
1217
+ * other entry, so velocities: [{x, y}] made particles with no velocity and no
1218
+ * word (VELOC23). Point objects are turned into pairs here. And a burst draws
1219
+ * `count` particles, the i-th taking velocities[i % length]: with fewer
1220
+ * velocities than particles the extras repeat a velocity and draw on top of an
1221
+ * earlier particle, and with more the extras go unused, both in silence
1222
+ * (VELOCMISMATCH23). That is said, not changed: cycling is the studio's rule.
1223
+ * `assumeDefaultCount` is false for a set, whose spec is only the keys changed.
1224
+ */
1225
+ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1226
+ const warnings = [];
1227
+ if (!spec || typeof spec !== 'object')
1228
+ return { spec, warnings };
1229
+ const pairs = (v) => (Array.isArray(v)
1230
+ ? v.map((p) => (p && typeof p === 'object' && !Array.isArray(p) && ('x' in p || 'y' in p)
1231
+ ? [Number(p.x ?? 0), Number(p.y ?? 0)]
1232
+ : p))
1233
+ : v);
1234
+ const fix = (o) => {
1235
+ const out = { ...o };
1236
+ for (const k of ['velocities', 'offsets'])
1237
+ if (out[k] !== undefined)
1238
+ out[k] = pairs(out[k]);
1239
+ return out;
1240
+ };
1241
+ const out = fix(spec);
1242
+ if (Array.isArray(spec.bursts))
1243
+ out.bursts = spec.bursts.map((b) => (b && typeof b === 'object' ? fix(b) : b));
1244
+ // The studio's default count per burst (ParticleEmitter DEFAULT_LOOK).
1245
+ const DEFAULT_COUNT = 12;
1246
+ const mismatch = (where, count, velocities) => {
1247
+ if (!Array.isArray(velocities) || !velocities.length)
1248
+ return;
1249
+ const n = typeof count === 'number' && Number.isFinite(count) ? Math.round(count) : (assumeDefaultCount ? DEFAULT_COUNT : null);
1250
+ if (n === null || n === velocities.length)
1251
+ return;
1252
+ warnings.push(n > velocities.length
1253
+ ? `${where}: count ${n} with ${velocities.length} velocities: particle i takes velocities[i % ${velocities.length}], so ${n - velocities.length} particles repeat an earlier particle's path and draw on top of it. Give one velocity per particle, or set count to ${velocities.length}.`
1254
+ : `${where}: count ${n} with ${velocities.length} velocities: only the first ${n} velocities are used. Set count to ${velocities.length} to draw them all.`);
1255
+ };
1256
+ // The spec's own pair once; a burst only where it sets either itself (it inherits the rest).
1257
+ const bursts = Array.isArray(out.bursts) ? out.bursts : [];
1258
+ const inherited = bursts.some((b) => !b || typeof b !== 'object' || (b.count === undefined && b.velocities === undefined));
1259
+ if (out.velocities !== undefined && (out.count !== undefined || inherited))
1260
+ mismatch('spec', out.count, out.velocities);
1261
+ bursts.forEach((b, i) => {
1262
+ const own = (b && typeof b === 'object' ? b : {});
1263
+ if (own.count !== undefined || own.velocities !== undefined)
1264
+ mismatch(`bursts[${i}]`, own.count ?? out.count, own.velocities ?? out.velocities);
1265
+ });
1266
+ return { spec: out, warnings };
1267
+ }
1194
1268
  /**
1195
1269
  * moves_along_path, from the parameters a caller actually writes.
1196
1270
  *
@@ -2044,9 +2118,11 @@ const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
2044
2118
  */
2045
2119
  function generateExportSVGCode() {
2046
2120
  return `
2047
- // Export animated SVG
2121
+ // Export animated SVG. An export that produced nothing is a failure, named, not a success with an empty string.
2048
2122
  const svgString = app.exportAnimatedSVG();
2049
- ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
2123
+ (typeof svgString !== 'string' || svgString.length === 0)
2124
+ ? ({ success: false, step: 'export', error: 'export failed at the export step: exportAnimatedSVG returned ' + (svgString === '' ? 'an empty string' : (svgString === null ? 'null' : typeof svgString)) + ', so there is no SVG to return' })
2125
+ : ({ success: true, svgString, hasAnimations: svgString.includes('<animate') });
2050
2126
  `.trim();
2051
2127
  }
2052
2128
  /**
@@ -2374,6 +2450,15 @@ ${itemSettersApplyJs(s, '__sid', '__res')}
2374
2450
  return __res;
2375
2451
  })();`;
2376
2452
  }
2453
+ /**
2454
+ * A batch op's item reference as a JS expression: `$N` is the Nth item this
2455
+ * batch created, anything else is an id, quoted. Only digits follow the `$` —
2456
+ * the raw splice it replaces put `$1);…;(` straight into the snippet, and an id
2457
+ * with a quote in it broke the batch's syntax.
2458
+ */
2459
+ export function batchRef(raw) {
2460
+ return typeof raw === 'string' && /^\$\d+$/.test(raw) ? `__ref(${raw.slice(1)})` : JSON.stringify(String(raw));
2461
+ }
2377
2462
  function asReturningBody(snippet) {
2378
2463
  const single = snippet.trim();
2379
2464
  if (single.startsWith('(async function()'))
@@ -4265,11 +4350,16 @@ throw new Error('Unknown diagram mode action: ${action}');
4265
4350
  */
4266
4351
  generateAgentBatchExecute(input) {
4267
4352
  const validated = AgentBatchExecuteInputSchema.parse(input);
4268
- const { operations, atomic } = validated;
4353
+ const { atomic } = validated;
4354
+ // ring / grid / orbit / stagger / patch become the batch's own ops (batch-macros.ts).
4355
+ const operations = expandBatchMacros(validated.operations);
4269
4356
  const isAtomic = atomic !== false;
4270
4357
  // Fold duplicate keyframe_animate targets so a later op can't silently
4271
4358
  // clobber an earlier track (app.addAnimation replaces, never merges).
4272
4359
  const mergePlan = planKeyframeMerges(operations);
4360
+ // Timeline ops compile here, on the server: the handler refuses a batch whose timeline does not compile before
4361
+ // anything runs; a direct caller gets the op's own failure instead.
4362
+ const timelines = compileBatchTimelines(operations);
4273
4363
  let code = `
4274
4364
  // Batch execute ${operations.length} operations
4275
4365
  (async function() {
@@ -4298,11 +4388,32 @@ throw new Error('Unknown diagram mode action: ${action}');
4298
4388
  };
4299
4389
  const results = [];
4300
4390
  let success = true;
4391
+ // A failure's words, never "undefined" for a thrown non-Error.
4392
+ const _why = (e, fb) => (e && typeof e.message === 'string' && e.message.trim()) ? e.message
4393
+ : (typeof e === 'string' && e.trim()) ? e : fb;
4394
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
4395
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
4396
+ // result. A studio without lintScene says nothing.
4397
+ const __ppLintNow = function () {
4398
+ try {
4399
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
4400
+ const r = app.lintScene();
4401
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
4402
+ } catch (e) {
4403
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
4404
+ }
4405
+ };
4406
+ // Every failed op as { step, itemId, reason }, beside the per-op results.
4407
+ const _failures = () => results.filter((r) => !r.success).map((r) => ({ step: r.step, itemId: r.itemId, reason: r.reason }));
4301
4408
 
4302
4409
  try {
4303
4410
  `;
4304
4411
  operations.forEach((op, index) => {
4305
- const opCode = this.generateBatchOperationCode(op, index, mergePlan);
4412
+ const opCode = op.type === 'timeline'
4413
+ ? (timelines.plans.has(index)
4414
+ ? emitTimelineOpBody(timelines.plans.get(index), (t) => (t.startsWith('$') ? `__ref(${t.substring(1)})` : JSON.stringify(t)))
4415
+ : `return { success: false, error: ${JSON.stringify(timelines.errors.filter((e) => e.path.startsWith(`operations[${index}]`)).map((e) => `${e.path}: ${e.message}`).join('; ') || 'the timeline did not compile')} };`)
4416
+ : this.generateBatchOperationCode(op, index, mergePlan);
4306
4417
  // $N variable references are documented as "items CREATED in earlier
4307
4418
  // operations" — only `create` results may extend the itemIds array.
4308
4419
  // animate/keyframe_animate/modify/delete also return { itemId } (the
@@ -4313,6 +4424,10 @@ throw new Error('Unknown diagram mode action: ${action}');
4313
4424
  // vehicles, 2026-07-30). `group` already follows this rule by
4314
4425
  // returning groupId instead of itemId.
4315
4426
  const claimsItemSlot = op.type === 'create';
4427
+ // The item this op acts on, for a failure's itemId: its own id field, else what its result names.
4428
+ const o = op;
4429
+ const opItem = [o.itemId, o.targetId, o.sourceId, o.id].find((v) => typeof v === 'string') ?? null;
4430
+ const opStep = typeof o._step === 'string' ? `op ${index}: ${o._step}` : `op ${index} (${op.type})`;
4316
4431
  code += `
4317
4432
  // Operation ${index}: ${op.type}
4318
4433
  try {
@@ -4329,7 +4444,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4329
4444
  if (failed${index}) {
4330
4445
  // ATOMIC: throw WITHOUT recording, and let the catch below record it
4331
4446
  // once. Doing both put two entries in for one failed op.
4332
- ${isAtomic ? `throw new Error(failed${index});` : `results.push({ index: ${index}, success: false, error: failed${index}, result: result${index} });
4447
+ ${isAtomic ? `const __e${index} = new Error(failed${index}); __e${index}.itemId = (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}; throw __e${index};` : `results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (result${index} && typeof result${index}.itemId === 'string') ? result${index}.itemId : ${JSON.stringify(opItem)}, reason: failed${index}, error: failed${index}, result: result${index} });
4333
4448
  success = false;`}
4334
4449
  } else {
4335
4450
  results.push({ index: ${index}, success: true, result: result${index} });
@@ -4338,7 +4453,8 @@ throw new Error('Unknown diagram mode action: ${action}');
4338
4453
  }` : ''}
4339
4454
  }
4340
4455
  } catch (opError) {
4341
- results.push({ index: ${index}, success: false, error: opError.message });
4456
+ const __r${index} = _why(opError, 'operation ${index} (${op.type}) threw with no message');
4457
+ results.push({ index: ${index}, success: false, step: ${JSON.stringify(opStep)}, itemId: (opError && typeof opError.itemId === 'string') ? opError.itemId : ${JSON.stringify(opItem)}, reason: __r${index}, error: __r${index} });
4342
4458
  ${isAtomic ? 'throw opError;' : 'success = false;'}
4343
4459
  }
4344
4460
  `;
@@ -4358,15 +4474,19 @@ throw new Error('Unknown diagram mode action: ${action}');
4358
4474
  }
4359
4475
  return { success, itemIds, results, operationCount: ${operations.length},
4360
4476
  failedCount: _failed.length,
4361
- ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
4362
- canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4477
+ ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; '), failures: _failures() } : {}),
4478
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4363
4479
  } catch (e) {
4364
4480
  // A batch that created nothing keeps the previous batch's ids on record.
4365
4481
  if (typeof window !== 'undefined' && itemIds.length) {
4366
4482
  window.__pinepaper_batch_item_ids = itemIds;
4367
4483
  }
4368
4484
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4369
- return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4485
+ const __fails = _failures();
4486
+ const __reason = _why(e, 'the batch failed with no message');
4487
+ return { success: false, error: __reason, itemIds, results, operationCount: ${operations.length},
4488
+ failures: __fails.length ? __fails : [{ step: 'batch', itemId: null, reason: __reason }],
4489
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
4370
4490
  }
4371
4491
  })();
4372
4492
  `;
@@ -4403,7 +4523,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4403
4523
  'backgroundColor', 'generatorName', 'generatorParams', 'generatorRegion',
4404
4524
  'width', 'height', 'preset', 'keyframes', 'duration', 'loop',
4405
4525
  'maskPreset', 'maskType', 'maskOptions', 'effectType', 'effectParams',
4406
- 'action', 'time', 'append'
4526
+ 'action', 'time', 'append', '_step'
4407
4527
  ]);
4408
4528
  for (const [k, v] of Object.entries(anyOp)) {
4409
4529
  if (!batchKeys.has(k) && v !== undefined && opProps[k] === undefined) {
@@ -4448,9 +4568,7 @@ return __created;
4448
4568
  return createCode;
4449
4569
  }
4450
4570
  case 'modify': {
4451
- const itemRef = op.itemId?.startsWith('$')
4452
- ? `__ref(${op.itemId.substring(1)})`
4453
- : `'${op.itemId}'`;
4571
+ const itemRef = batchRef(op.itemId);
4454
4572
  // modify_item's own emitter, as create is create_item's (see above):
4455
4573
  // the batch's modifyItem call missed text styles, font loading, audio
4456
4574
  // level, no-fill, lifetimes, smoothing and the unread-property report.
@@ -4463,15 +4581,21 @@ return __created;
4463
4581
  .replace(/;\s*$/, '');
4464
4582
  return `
4465
4583
  const __mid = ${itemRef};
4584
+ if (app.itemRegistry && app.itemRegistry.getItem && !app.itemRegistry.getItem(__mid) && typeof app.getItemsByName === 'function') {
4585
+ // A NAME HELD BY SEVERAL ITEMS is refused, not resolved: getItemById answers
4586
+ // the first holder, so a patch by name would change whichever came first.
4587
+ const __held = app.getItemsByName(__mid) || [];
4588
+ if (__held.length > 1) {
4589
+ return { success: false, itemId: __mid, error: __held.length + ' items are named ' + JSON.stringify(__mid) + ' (' + __held.map(function (h) { return (h && h.data && h.data.id) || '?'; }).join(', ') + '); pass one of their ids.' };
4590
+ }
4591
+ }
4466
4592
  const __r = await ${single};
4467
4593
  if (__r && __r.success !== false) __r.modified = true;
4468
4594
  return __r;
4469
4595
  `;
4470
4596
  }
4471
4597
  case 'animate':
4472
- const animItemRef = op.itemId?.startsWith('$')
4473
- ? `__ref(${op.itemId.substring(1)})`
4474
- : `'${op.itemId}'`;
4598
+ const animItemRef = batchRef(op.itemId);
4475
4599
  const animOpts = JSON.stringify(op.animationOptions || {});
4476
4600
  return `
4477
4601
  const targetId = ${animItemRef};
@@ -4481,12 +4605,8 @@ app.animate(item, { animationType: '${op.animationType}', ...${animOpts} });
4481
4605
  return { itemId: targetId, animationType: '${op.animationType}' };
4482
4606
  `;
4483
4607
  case 'relation':
4484
- const srcRef = op.sourceId?.startsWith('$')
4485
- ? `__ref(${op.sourceId.substring(1)})`
4486
- : `'${op.sourceId}'`;
4487
- const tgtRef = op.targetId?.startsWith('$')
4488
- ? `__ref(${op.targetId.substring(1)})`
4489
- : `'${op.targetId}'`;
4608
+ const srcRef = batchRef(op.sourceId);
4609
+ const tgtRef = batchRef(op.targetId);
4490
4610
  const relOpts = JSON.stringify(op.relationOptions || {});
4491
4611
  return `
4492
4612
  const sourceId = ${srcRef};
@@ -4508,9 +4628,7 @@ if (!relationId) {
4508
4628
  return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
4509
4629
  `;
4510
4630
  case 'delete':
4511
- const delRef = op.itemId?.startsWith('$')
4512
- ? `__ref(${op.itemId.substring(1)})`
4513
- : `'${op.itemId}'`;
4631
+ const delRef = batchRef(op.itemId);
4514
4632
  return `
4515
4633
  const targetId = ${delRef};
4516
4634
  const item = app.getItemById(targetId);
@@ -4521,7 +4639,7 @@ return { itemId: targetId, deleted: true };
4521
4639
  `;
4522
4640
  case 'group': {
4523
4641
  // Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
4524
- const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `__ref(${ref.substring(1)})` : `'${ref}'`);
4642
+ const refs = (op.itemIds || []).map((ref) => batchRef(ref));
4525
4643
  const idsArrayExpr = `[${refs.join(', ')}]`;
4526
4644
  const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
4527
4645
  // Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
@@ -4533,8 +4651,8 @@ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
4533
4651
  case 'set_background':
4534
4652
  const bgColor = op.backgroundColor || op.color || op.properties?.backgroundColor || op.properties?.color || '#000000';
4535
4653
  return `
4536
- app.setBackgroundColor('${bgColor}');
4537
- return { success: true, backgroundColor: '${bgColor}' };
4654
+ app.setBackgroundColor(${JSON.stringify(String(bgColor))});
4655
+ return { success: true, backgroundColor: ${JSON.stringify(String(bgColor))} };
4538
4656
  `;
4539
4657
  case 'execute_generator':
4540
4658
  const genName = op.generatorName || 'drawSunburst';
@@ -4565,14 +4683,12 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
4565
4683
  return `
4566
4684
  // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
4567
4685
  // is replaced wholesale, so emitting both here would silently drop one.
4568
- return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)})` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
4686
+ return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget} };
4569
4687
  `;
4570
4688
  }
4571
4689
  const mergedPayload = mergePlan?.merged.get(index);
4572
4690
  const rawItemId = String(op.itemId ?? op.id ?? op.properties?.itemId ?? op.properties?.id);
4573
- const kfItemRef = rawItemId?.startsWith('$')
4574
- ? `__ref(${rawItemId.substring(1)})`
4575
- : `'${rawItemId}'`;
4691
+ const kfItemRef = batchRef(rawItemId);
4576
4692
  const kfSource = mergedPayload
4577
4693
  ? mergedPayload.keyframes
4578
4694
  : (op.keyframes || (op.properties && op.properties.keyframes) || (op.params && op.params.keyframes) || []);
@@ -4614,9 +4730,7 @@ return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoo
4614
4730
  `;
4615
4731
  }
4616
4732
  case 'apply_mask': {
4617
- const maskItemRef = op.itemId?.startsWith('$')
4618
- ? `__ref(${op.itemId.substring(1)})`
4619
- : `'${op.itemId}'`;
4733
+ const maskItemRef = batchRef(op.itemId);
4620
4734
  const maskOpts = JSON.stringify(op.maskOptions || {});
4621
4735
  const preset = op.maskPreset || op.maskType || 'wipeLeft';
4622
4736
  return `
@@ -4633,9 +4747,7 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
4633
4747
  `;
4634
4748
  }
4635
4749
  case 'apply_effect': {
4636
- const effItemRef = op.itemId?.startsWith('$')
4637
- ? `__ref(${op.itemId.substring(1)})`
4638
- : `'${op.itemId}'`;
4750
+ const effItemRef = batchRef(op.itemId);
4639
4751
  const effParams = JSON.stringify(op.effectParams || {});
4640
4752
  return `
4641
4753
  const targetId = ${effItemRef};
@@ -5120,6 +5232,19 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5120
5232
  // cannot be kept accurate — it breaks on any edit above it — so this repo
5121
5233
  // does not use them. Camera animation still
5122
5234
  // drives motion during export — framing only fixes the output frame.
5235
+ // THE SCENE LINT, BEFORE THE EXPORT (FxTool lintScene().warnings): what the file will show wrong without
5236
+ // anything failing — a centre-anchored text that runs out of the frame exports as "IGNAL" for "SIGNAL". Read from the
5237
+ // scene as it is, never a refusal; a studio without the rule says nothing, a lint that throws says so.
5238
+ const __lint = (function () {
5239
+ if (typeof app.lintScene !== 'function') return null;
5240
+ try {
5241
+ const r = app.lintScene();
5242
+ return r && Array.isArray(r.warnings) ? r.warnings : null;
5243
+ } catch (e) {
5244
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run before the export: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
5245
+ }
5246
+ })();
5247
+
5123
5248
  let cameraDims = null;
5124
5249
  if (framing === 'camera') {
5125
5250
  if (!['gif', 'apng', 'mp4', 'webm'].includes(format)) {
@@ -5668,7 +5793,7 @@ ${stillTime !== undefined ? `
5668
5793
  if (typeof fr.embedded === 'number') __tl.fonts.embedded = fr.embedded; // cumulative for the document
5669
5794
  if (typeof fr.hiddenTextItems === 'number') __tl.fonts.hiddenTextItems = (__tl.fonts.hiddenTextItems || 0) + fr.hiddenTextItems;
5670
5795
  }
5671
- } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: e && e.message }; }
5796
+ } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: (e && typeof e.message === 'string' && e.message) || 'embedding the text fonts failed with no reason given' }; }
5672
5797
  }
5673
5798
  if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
5674
5799
  if (__layer) {
@@ -5676,7 +5801,7 @@ ${stillTime !== undefined ? `
5676
5801
  const tr = await app.exportEngine.addTextLayer(doc, { x: 0, y: 0, width: wMM, height: hMM });
5677
5802
  if (tr && tr.ok !== false) { __tl.written += tr.linesWritten || 0; __tl.skipped += tr.linesSkipped || 0; }
5678
5803
  else __tl.reasons.push(id + ': ' + ((tr && tr.reason) || 'refused'));
5679
- } catch (e) { __tl.reasons.push(id + ': ' + e.message); }
5804
+ } catch (e) { __tl.reasons.push(id + ': ' + ((e && typeof e.message === 'string' && e.message) || 'the text layer failed with no reason given')); }
5680
5805
  }
5681
5806
  pagesOut.push({ sceneId: id, widthMM: Math.round(wMM * 10) / 10, heightMM: Math.round(hMM * 10) / 10 });
5682
5807
  }
@@ -5739,7 +5864,7 @@ ${stillTime !== undefined ? `
5739
5864
  result = { success: false, error: 'Unknown format: ' + format };
5740
5865
  }
5741
5866
  } catch (e) {
5742
- result = { success: false, error: e.message, platform, format };
5867
+ result = { success: false, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('the ' + format + ' export threw with no message'), platform, format };
5743
5868
  }
5744
5869
 
5745
5870
  // One site, after every branch has set its result: a successful export says
@@ -5777,11 +5902,19 @@ ${stillTime !== undefined ? `
5777
5902
  result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });
5778
5903
  }
5779
5904
  } catch (e) {
5780
- result = { success: false, platform, format, error: (e && e.message) || String(e) };
5905
+ result = { success: false, platform, format, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('re-encoding the still as ' + format + ' failed with no message') };
5781
5906
  }
5782
5907
  }
5783
5908
 
5784
5909
  if (result && result.success) Object.assign(result, fidelity(format));
5910
+ if (result && result.success && __lint && __lint.length && !result.lintWarnings) {
5911
+ result.lintWarnings = __lint;
5912
+ result.fidelity = result.fidelity || { warnings: [] };
5913
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__lint.map(function (w) {
5914
+ return { code: String(w.code || 'lint'), message: String(w.message || w.code || 'scene lint warning'), ...(w.itemId ? { items: [w.itemId] } : {}) };
5915
+ }));
5916
+ if (result.fidelity.note) delete result.fidelity.note;
5917
+ }
5785
5918
  if (result && result.success && __fit) result.platformFit = __fit;
5786
5919
  ${usesCanvasSize ? '' : ` // A PRESET SIZE IS NOT THE CANVAS SIZE, AND THAT IS SAID (21 PLATFORMOVERRIDE).
5787
5920
  // A named platform renders the FILE at the preset's size; the canvas is not
@@ -7713,14 +7846,39 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
7713
7846
  // An async IIFE is still a `(`-led expression statement, so the governor
7714
7847
  // captures its value, and executeCode's eval fallback already resolves a
7715
7848
  // returned promise. Both paths await it; neither needed changing.
7849
+ //
7850
+ // The code runs inside its own async arrow so its `return` is still its value; the value comes back under
7851
+ // __ppValue with the scene lint beside it, and a throw comes back as { step, itemId, reason }, never a bare
7852
+ // message (and never "undefined" for a thrown non-Error).
7716
7853
  return `
7717
7854
  // ${description}
7718
7855
  (async function() {
7856
+ // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
7857
+ // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
7858
+ // result. A studio without lintScene says nothing.
7859
+ const __ppLintNow = function () {
7860
+ try {
7861
+ if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];
7862
+ const r = app.lintScene();
7863
+ return r && Array.isArray(r.warnings) ? r.warnings : [];
7864
+ } catch (e) {
7865
+ return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could not run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];
7866
+ }
7867
+ };
7868
+ let __ppValue;
7719
7869
  try {
7870
+ __ppValue = await (async () => {
7720
7871
  ${validated.code}
7872
+ })();
7721
7873
  } catch (error) {
7722
- return { success: false, error: error.message };
7723
- }
7874
+ const __ppReason = (error && typeof error.message === 'string' && error.message.trim()) ? error.message
7875
+ : (typeof error === 'string' && error.trim()) ? error : 'the custom code threw with no message';
7876
+ return { success: false, step: 'execute_custom_code', itemId: (error && typeof error.itemId === 'string') ? error.itemId : null,
7877
+ reason: __ppReason, error: __ppReason, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };
7878
+ }
7879
+ // Only wrapped when the lint has something to say: otherwise the value is returned exactly as before.
7880
+ const __ppL = __ppLintNow();
7881
+ return __ppL.length ? { __ppValue: __ppValue, __ppLint: __ppL } : __ppValue;
7724
7882
  })();
7725
7883
  `.trim();
7726
7884
  }
@@ -8528,6 +8686,53 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
8528
8686
  };
8529
8687
  })();
8530
8688
  `.trim();
8689
+ }
8690
+ /**
8691
+ * pinepaper_pattern (AR-2 P2): one stored declaration moves many items (FxTool app.addPattern / modifyPattern /
8692
+ * removePattern / listPatterns / getPattern). No keyframes are written; the engine evaluates the node per frame.
8693
+ */
8694
+ generatePattern(input) {
8695
+ const v = PatternInputSchema.parse(input);
8696
+ const S = (x) => JSON.stringify(x);
8697
+ const need = (fn) => `if (typeof app.${fn} !== 'function') return { success: false, error: 'app.${fn} unavailable — this studio predates patterns (FxTool AR-2 P1); update it' };`;
8698
+ const idNeeded = v.action !== 'list' && !v.id ? `return { success: false, error: '${v.action} needs the pattern id' };` : '';
8699
+ let body;
8700
+ switch (v.action) {
8701
+ case 'create': {
8702
+ const spec = { id: v.id, kind: v.kind, members: v.members, params: v.params || {}, ...(v.window ? { window: v.window } : {}) };
8703
+ body = `${need('addPattern')}
8704
+ if (!${S(v.kind ?? null)}) return { success: false, error: 'create needs kind: phase_wave or stagger' };
8705
+ if (!${S(v.members ?? null)}) return { success: false, error: 'create needs members: { names: [...] }, { ids: [...] }, { tag } or an array' };
8706
+ const r = app.addPattern(${S(spec)});
8707
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8708
+ break;
8709
+ }
8710
+ case 'modify': {
8711
+ const ch = { ...(v.params ? { params: v.params } : {}), ...(v.members ? { members: v.members } : {}), ...(v.window !== undefined ? { window: v.window } : {}) };
8712
+ body = `${need('modifyPattern')}
8713
+ const r = app.modifyPattern(${S(v.id)}, ${S(ch)});
8714
+ return Object.assign({ success: !!(r && r.ok) }, r || {}, app.lastReceipt ? { receipt: app.lastReceipt } : {});`;
8715
+ break;
8716
+ }
8717
+ case 'remove':
8718
+ body = `${need('removePattern')}
8719
+ const had = app.removePattern(${S(v.id)});
8720
+ return had ? { success: true, removed: ${S(v.id)} } : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8721
+ break;
8722
+ case 'get':
8723
+ body = `${need('getPattern')}
8724
+ const p = app.getPattern(${S(v.id)});
8725
+ return p ? Object.assign({ success: true }, p) : { success: false, error: 'no pattern ' + ${S(JSON.stringify(v.id ?? ''))} };`;
8726
+ break;
8727
+ default:
8728
+ body = `${need('listPatterns')}
8729
+ const list = app.listPatterns();
8730
+ return { success: true, count: list.length, patterns: list };`;
8731
+ }
8732
+ return `(function() {
8733
+ ${idNeeded}
8734
+ ${body}
8735
+ })();`;
8531
8736
  }
8532
8737
  generateStagger(input) {
8533
8738
  const validated = StaggerInputSchema.parse(input);
@@ -10368,6 +10573,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10368
10573
  (async function() {
10369
10574
  if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
10370
10575
  const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
10576
+ if (!result || typeof result.json !== 'string' || result.json.length === 0) {
10577
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidget returned no scene JSON' + (result && result.error ? ' (' + result.error + ')' : '') };
10578
+ }
10371
10579
  // THE EMBED BOX WAS ALWAYS 16:9, AND IT IS NOT SELF-CONTAINED (round 9 GG,
10372
10580
  // 8.32). The engine writes aspect-ratio: 16/9 and max-width: 800px whatever
10373
10581
  // the scene, so a 300x250 ad sat letterboxed in a wide box. The box is sized
@@ -10403,6 +10611,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10403
10611
  (async function() {
10404
10612
  if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
10405
10613
  const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
10614
+ if (!result || typeof result.html !== 'string' || result.html.length === 0) {
10615
+ return { success: false, step: 'export', error: 'export failed at the export step: exportWidgetHTML returned no page' + (result && result.error ? ' (' + result.error + ')' : '') };
10616
+ }
10406
10617
  ${input.lang || input.dir || input.alt ? `// lang / dir / alt on the engine's page (it writes <html lang="en"> and no
10407
10618
  // text alternative). Applied as string edits on the one <html> tag and the
10408
10619
  // first <canvas>; the result says which landed.
@@ -11325,7 +11536,10 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11325
11536
  return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
11326
11537
  }
11327
11538
  const result = app.lintScene(${optsJson});
11328
- return { success: true, density: result.density, suggestions: result.suggestions };
11539
+ // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the
11540
+ // frame edge). Passed through when the studio reports them; an older studio has none to pass.
11541
+ return { success: true, density: result.density, suggestions: result.suggestions,
11542
+ ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}) };
11329
11543
  })();`.trim();
11330
11544
  }
11331
11545
  /**
@@ -12251,8 +12465,10 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12251
12465
  }
12252
12466
  }
12253
12467
  /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
12254
- generateEmitter(input) {
12468
+ generateEmitter(raw) {
12255
12469
  const S = (v) => JSON.stringify(v);
12470
+ const normalized = normalizeEmitterSpec(raw.spec, raw.action !== 'set');
12471
+ const input = { ...raw, spec: normalized.spec };
12256
12472
  // A COUNT OVER THE CAP IS SAID (B144). The cap is 10000 a burst on the
12257
12473
  // current studio and was 2000 before it, clamped in silence (2300 drew
12258
12474
  // ~1940). Which applies is read from the spec the studio STORED — its
@@ -12274,7 +12490,10 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12274
12490
  }
12275
12491
  return out;
12276
12492
  }` : '';
12277
- const capJs = (stored) => checks.length ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(__capWarnings(${stored})))` : '';
12493
+ const fixed = normalized.warnings;
12494
+ const capJs = (stored) => checks.length || fixed.length
12495
+ ? `, ...((function(w) { return w.length ? { warnings: w } : {}; })(${S(fixed)}.concat(${checks.length ? `__capWarnings(${stored})` : '[]'})))`
12496
+ : '';
12278
12497
  if (input.action === 'set') {
12279
12498
  return `
12280
12499
  // Emitter: change a spec
@@ -13562,10 +13781,15 @@ ${guard('exportPNGSequence')}
13562
13781
  return Object.assign({ success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size }, __pngSeqDefaults);
13563
13782
  }
13564
13783
  if (Array.isArray(out)) {
13565
- return { success: true, format: 'png-sequence', frames: out.length,
13784
+ // NOT A SAVED FILE, and said so in fields: file null, and why.
13785
+ return { success: true, format: 'png-sequence', frames: out.length, file: null,
13786
+ why: 'the frames stayed in the page: the studio returned them as a list of frames, not a file, so no file was written. Pass download: true to have the browser download them as a zip.',
13566
13787
  note: 'the frames stayed in the page — pass download: true to write them out.' };
13567
13788
  }
13568
- return { success: true, format: 'png-sequence', result: out };
13789
+ // download: true — the studio handed the zip to the BROWSER's download, so no bytes reached this server and no file
13790
+ // was written by it. Said in fields, so it cannot be read as a saved file.
13791
+ return { success: true, format: 'png-sequence', result: out, file: null,
13792
+ why: 'the studio handed the zip to the browser as a download (download: true): it went to the browser\\'s download folder, no bytes reached the MCP server, and the server wrote no file.' };
13569
13793
  })();`.trim();
13570
13794
  }
13571
13795
  }