@pinepaper.studio/mcp-server 1.6.34 → 1.6.36

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 (63) 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 -6
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  11. package/dist/browser/puppeteer-controller.js +4 -0
  12. package/dist/browser/puppeteer-controller.js.map +1 -1
  13. package/dist/build-info.json +2 -2
  14. package/dist/character/perform.d.ts +14 -0
  15. package/dist/character/perform.d.ts.map +1 -1
  16. package/dist/character/perform.js +29 -3
  17. package/dist/character/perform.js.map +1 -1
  18. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  19. package/dist/ontology/upstream-vocabulary.js +1 -1
  20. package/dist/tools/definitions.d.ts.map +1 -1
  21. package/dist/tools/definitions.js +113 -67
  22. package/dist/tools/definitions.js.map +1 -1
  23. package/dist/tools/effect-class.d.ts.map +1 -1
  24. package/dist/tools/effect-class.js +4 -0
  25. package/dist/tools/effect-class.js.map +1 -1
  26. package/dist/tools/engine-lists.d.ts +4 -4
  27. package/dist/tools/engine-lists.d.ts.map +1 -1
  28. package/dist/tools/engine-lists.js +13 -4
  29. package/dist/tools/engine-lists.js.map +1 -1
  30. package/dist/tools/engine-methods.d.ts.map +1 -1
  31. package/dist/tools/engine-methods.js +4 -0
  32. package/dist/tools/engine-methods.js.map +1 -1
  33. package/dist/tools/engine-surface.d.ts +1 -1
  34. package/dist/tools/engine-surface.d.ts.map +1 -1
  35. package/dist/tools/engine-surface.js +19 -8
  36. package/dist/tools/engine-surface.js.map +1 -1
  37. package/dist/tools/handlers/beats.d.ts +1 -1
  38. package/dist/tools/handlers.d.ts +7 -0
  39. package/dist/tools/handlers.d.ts.map +1 -1
  40. package/dist/tools/handlers.js +43 -3
  41. package/dist/tools/handlers.js.map +1 -1
  42. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  43. package/dist/tools/minimal-descriptions.js +1 -0
  44. package/dist/tools/minimal-descriptions.js.map +1 -1
  45. package/dist/tools/shape-params.d.ts +2 -2
  46. package/dist/tools/shape-params.js +2 -2
  47. package/dist/tools/stick-vocabulary.d.ts +1 -1
  48. package/dist/tools/stick-vocabulary.js +1 -1
  49. package/dist/tools/toolkits.d.ts.map +1 -1
  50. package/dist/tools/toolkits.js +2 -1
  51. package/dist/tools/toolkits.js.map +1 -1
  52. package/dist/types/code-generator.d.ts +39 -1
  53. package/dist/types/code-generator.d.ts.map +1 -1
  54. package/dist/types/code-generator.js +366 -63
  55. package/dist/types/code-generator.js.map +1 -1
  56. package/dist/types/schemas.d.ts +100 -33
  57. package/dist/types/schemas.d.ts.map +1 -1
  58. package/dist/types/schemas.js +34 -12
  59. package/dist/types/schemas.js.map +1 -1
  60. package/dist/utils/asset-paths.js +1 -1
  61. package/dist/utils/asset-paths.js.map +1 -1
  62. package/manifest.json +5 -1
  63. package/package.json +1 -1
@@ -374,7 +374,10 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
374
374
  createScale.scaleY = num(properties.scaleY);
375
375
  const hasCreateScale = Object.keys(createScale).length > 0;
376
376
  const SCALE_KEYS = ['scale', 'scaleX', 'scaleY'];
377
- const ignored = Object.keys(properties).filter((k) => !(hasCreateScale && SCALE_KEYS.includes(k) && num(properties[k]) !== undefined)).filter((k) => itemType === 'text'
377
+ // VISIBLE AT CREATE. create() does not read visible, so a shape made hidden (a morph target, a reveal) was
378
+ // drawn; it was reported ignored. A boolean is applied to the item right after create.
379
+ const createVisible = typeof properties.visible === 'boolean' ? properties.visible : undefined;
380
+ const ignored = Object.keys(properties).filter((k) => !(k === 'visible' && createVisible !== undefined)).filter((k) => !(hasCreateScale && SCALE_KEYS.includes(k) && num(properties[k]) !== undefined)).filter((k) => itemType === 'text'
378
381
  ? (!CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k))
379
382
  || (k === 'letterSpacing' && letterSpacingCss(properties[k]) === undefined)
380
383
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
@@ -422,6 +425,10 @@ const item = app.create(${jsLiteral(itemType)}, ${JSON.stringify(params, null, 2
422
425
  if (opacity !== undefined) {
423
426
  code += `\nitem.opacity = ${opacity};`;
424
427
  }
428
+ if (createVisible !== undefined) {
429
+ code += `
430
+ if (item) item.visible = ${createVisible};`;
431
+ }
425
432
  if (hasCreateScale) {
426
433
  code += `
427
434
  const __scale = (function(it) {
@@ -495,13 +502,14 @@ const __scale = (function(it) {
495
502
  if (lineIgnoresPosition) {
496
503
  warnings.push('a line runs from `from` to `to` in canvas coordinates, so position did not move it: position is where the line is now. Give the endpoints where the line goes.');
497
504
  }
498
- code += ("\nconst itemId = item.data.registryId;\napp.historyManager.saveState();\n\n// Return item info\n({ itemId, type: " +
505
+ code += ("\nconst itemId = item.data.registryId;\n// Debounced where the studio has it: a full snapshot per create made one-call-per-item building O(items) a call.\napp.historyManager[app.historyManager.saveStateDebounced ? 'saveStateDebounced' : 'saveState']();\n\n// Return item info\n({ itemId, type: " +
499
506
  String(jsLiteral(itemType)) +
500
507
  String(outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : '') +
501
508
  String(textFit ? ', textFit: __textFit' : '') +
502
509
  String(textDirection ? ', direction: __direction' : '') +
503
510
  String(textTabular ? ', tabularFigures: __tabular' : '') +
504
- String(textTypography.length ? ', typography: __typography' : '') + String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
511
+ String(textTypography.length ? ', typography: __typography' : '') +
512
+ String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
505
513
  ? `, ignoredProperties: ${JSON.stringify(ignored)}` : '') + String(warnings.length > 0 ? `, warning: ${JSON.stringify(warnings.join(' '))}` : '') + " });");
506
514
  // Loading a font is async, and it has to happen BEFORE create() measures the
507
515
  // text — so only this case becomes an async IIFE; every other create stays a
@@ -1558,6 +1566,31 @@ export function animationOptionWarning(options) {
1558
1566
  return unreadKeysWarning('animationOptions', ANIMATION_OPTION_KEYS, options);
1559
1567
  }
1560
1568
  const VISIBILITY_REACTION = /^on_(event|enter|exit)_set_visibility$/;
1569
+ /** The relations that PULSE params.event (FxTool js/relations/triggers.js dispatchTrigger). */
1570
+ const EVENT_PULSING_RELATIONS = new Set(['on_complete', 'at_beat']);
1571
+ /**
1572
+ * ONCOMP34: AN EVENT'S NAME IS NOT ITS CHANNEL, here as in DBLEXP33. on_complete / at_beat pulse params.event
1573
+ * verbatim, and a reaction listens on its source's id, so {event: 'done'} (the name of event item_4) pulsed a
1574
+ * channel nothing listens on: bound, stored, no warning, never seen. A name exactly one event holds, and that no
1575
+ * item has as its id, is put back as that event's id before the bind; a name two events share is named instead.
1576
+ * Statements, run in the page; `paramsVar` is mutated in place and `__ev` holds the reply fields.
1577
+ */
1578
+ function eventNameResolveJs(relationType, paramsVar) {
1579
+ if (!EVENT_PULSING_RELATIONS.has(String(relationType)))
1580
+ return 'const __ev = {};';
1581
+ return `const __ev = {};
1582
+ (function(p) {
1583
+ if (!p || typeof p.event !== 'string' || !p.event || app.getItemById(p.event)) return;
1584
+ const ids = (app.itemRegistry && app.itemRegistry.getAll ? app.itemRegistry.getAll() : [])
1585
+ .filter(function(e) { return e && e.item && e.item.data && e.item.data.isEvent && e.item.data.eventName === p.event; })
1586
+ .map(function(e) { return e.id || e.itemId; });
1587
+ if (ids.length === 1) { __ev.eventResolvedFrom = p.event; __ev.eventResolvedTo = ids[0]; p.event = ids[0]; }
1588
+ else if (ids.length > 1) __ev.warning = ids.length + ' events are named ' + JSON.stringify(p.event) + ' (' + ids.join(', ') + '), so params.event cannot say which one: it pulses the literal name, which no reaction bound to an event hears. Pass one event id as params.event.';
1589
+ })(${paramsVar});`;
1590
+ }
1591
+ /** Spread into a reply after its own fields: the resolve fields, with an event warning joined onto any warning. */
1592
+ const EV_MERGE_JS = (replyVar, key = 'warning') => `Object.keys(__ev).forEach(function(k) { if (k !== 'warning') ${replyVar}[k] = __ev[k]; });
1593
+ if (__ev.warning) ${replyVar}.${key} = ${replyVar}.${key} ? ${replyVar}.${key} + ' ' + __ev.warning : __ev.warning;`;
1561
1594
  /**
1562
1595
  * A visibility reaction reads params.visible and nothing else: absent means show. `value`, which the other set_*
1563
1596
  * reactions take, was ignored, so {value: false} SHOWED the item and a hide did nothing (issue #29 O4).
@@ -1587,14 +1620,18 @@ String(JSON.stringify(sourceId)) +
1587
1620
  String(JSON.stringify(targetId)) +
1588
1621
  "].filter(function(id) { return !app._isKnownRelationEndpoint(norm(id)); });\n if (bad.length) {\n return { success: false, relationType: " +
1589
1622
  String(jsLiteral(relationType)) +
1590
- ", error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')\n + ' ' + (bad.length > 1 ? 'are not items' : 'is not an item') + ' on the canvas, so the relation would be stored and skipped every frame. Check the id (pinepaper_get_items lists them).' };\n }\n }\n const success = app.addRelation(" +
1623
+ ", error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')\n + ' ' + (bad.length > 1 ? 'are not items' : 'is not an item') + ' on the canvas, so the relation would be stored and skipped every frame. Check the id (pinepaper_get_items lists them).' };\n }\n }\n const __params = " +
1624
+ String(JSON.stringify(params, null, 2)) +
1625
+ ";\n " +
1626
+ String(eventNameResolveJs(relationType, '__params')) +
1627
+ "\n const success = app.addRelation(" +
1591
1628
  String(jsLiteral(sourceId)) +
1592
1629
  ", " +
1593
1630
  String(jsLiteral(targetId)) +
1594
1631
  ", " +
1595
1632
  String(jsLiteral(relationType)) +
1596
- ", " +
1597
- String(JSON.stringify(params, null, 2)) + ");\n if (!success) {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n return { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String(relationOptionFields(relationType, params, 'warning')) + " };\n})();\n").trim();
1633
+ ", __params);\n if (!success) {\n const __why = " +
1634
+ String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n const __reply = { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String(relationOptionFields(relationType, params, 'warning')) + " };\n " + String(EV_MERGE_JS('__reply')) + "\n return __reply;\n})();\n").trim();
1598
1635
  }
1599
1636
  /**
1600
1637
  * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
@@ -1732,6 +1769,11 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1732
1769
  const f = o.fade;
1733
1770
  if (f && typeof f === 'object' && !Array.isArray(f))
1734
1771
  scalarRanges(`${where}.fade`, f, ['in', 'out']);
1772
+ // fade takes 'none', 'linear' (or true / false) or { in, out }. Any other word is not read, so the
1773
+ // particles keep the default linear fade over their life: fade 'out' looked like an option (EMITFADE34).
1774
+ if (typeof f === 'string' && f !== 'none' && f !== 'linear') {
1775
+ warnings.push(`${where}.fade ${JSON.stringify(f)} is not a fade the studio reads, so the particles keep the default linear fade over their whole life. Use 'none', 'linear', or { in, out } in seconds (or fadeIn / fadeOut).`);
1776
+ }
1735
1777
  };
1736
1778
  scalarRanges('spec', out, [...SCALAR_ONLY, ...SPEC_ONLY]);
1737
1779
  bursts.forEach((b, i) => { if (b && typeof b === 'object')
@@ -2316,6 +2358,29 @@ function generateExecuteGeneratorCode(generatorName, params, region) {
2316
2358
  * at all and draws full-canvas). So the item is checked first and refused by
2317
2359
  * name, and the result says whether this studio clips to shapes.
2318
2360
  */
2361
+ /** Relations the studio declares ONCE over a set (multiInstance: false in js/relations/collection.js). */
2362
+ export const COLLECTION_RELATIONS = new Set(['phase_wave', 'ring_forming', 'flock']);
2363
+ /**
2364
+ * The part of an export region that is rendered: the region itself, or, when
2365
+ * outputWidth / outputHeight have another aspect, the largest box of that aspect
2366
+ * centred in it (a cover crop). Never larger than the region.
2367
+ */
2368
+ export function regionCoverCrop(r) {
2369
+ const outW = r.outputWidth ?? Math.round(r.width);
2370
+ const outH = r.outputHeight ?? Math.round(r.height);
2371
+ const want = outW / outH, have = r.width / r.height;
2372
+ // Under half a source pixel of difference on either side is the same box.
2373
+ if (!(outW > 0 && outH > 0) || Math.abs(r.width - r.height * want) < 0.5 && Math.abs(r.height - r.width / want) < 0.5) {
2374
+ return { x: r.x, y: r.y, width: r.width, height: r.height, cropped: false };
2375
+ }
2376
+ const round = (v) => Math.round(v * 1000) / 1000;
2377
+ if (want > have) {
2378
+ const h = r.width / want;
2379
+ return { x: r.x, y: round(r.y + (r.height - h) / 2), width: r.width, height: round(h), cropped: true };
2380
+ }
2381
+ const w = r.height * want;
2382
+ return { x: round(r.x + (r.width - w) / 2), y: r.y, width: round(w), height: r.height, cropped: true };
2383
+ }
2319
2384
  function emitGeneratorRun(generatorName, paramsJson, region) {
2320
2385
  const options = JSON.stringify(region ? { region } : {});
2321
2386
  const itemId = region && typeof region.shape === 'object' ? region.shape.itemId : null;
@@ -3108,6 +3173,15 @@ function asReturningBody(snippet) {
3108
3173
  * out, and the batch saves once at the end, which is what batchCreate was
3109
3174
  * for. An item that fails is reported by index; the rest are still created.
3110
3175
  */
3176
+ /**
3177
+ * A create inside a batch skips the engine's per-item select, snapshot and redraw (`_bulkCreate`, the template
3178
+ * loader's path); the batch saves and draws once at the end. A governed run gets this from the engine already, but a
3179
+ * batch over the governor's size limit runs ungoverned, and there every create redrew the whole scene: 400 creates
3180
+ * on 2,000 items spent 2 of 5 s in full redraws.
3181
+ */
3182
+ function asBulkCreate(code) {
3183
+ return code.replace(/(const item = app\.create\((?:\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x27), )\{/g, '$1{ _bulkCreate: true, ');
3184
+ }
3111
3185
  function generateBatchCreateCode(items) {
3112
3186
  const blocks = items.map((it, i) => {
3113
3187
  const { x, y, position, ...props } = (it.params || {});
@@ -3115,8 +3189,8 @@ function generateBatchCreateCode(items) {
3115
3189
  : (position && typeof position === 'object') ? { x: Number(position.x), y: Number(position.y) }
3116
3190
  : { x: typeof x === 'number' ? x : 400, y: typeof y === 'number' ? y : 300 };
3117
3191
  const given = position !== undefined || typeof x === 'number' || typeof y === 'number';
3118
- const body = asReturningBody(generateCreateItemCode(it.type, p, props, undefined, given))
3119
- .replace(/app\.historyManager\.saveState\(\);/g, '');
3192
+ const body = asBulkCreate(asReturningBody(generateCreateItemCode(it.type, p, props, undefined, given)))
3193
+ .replace(/app\.historyManager(?:\.saveState\(\)|\[app\.historyManager\.saveStateDebounced \? 'saveStateDebounced' : 'saveState'\]\(\));/g, '');
3120
3194
  return `
3121
3195
  try {
3122
3196
  const r${i} = await (async function() {
@@ -3133,6 +3207,8 @@ ${body}
3133
3207
  const errors = [];
3134
3208
  ${blocks.join('\n')}
3135
3209
  if (app.historyManager) app.historyManager.saveState();
3210
+ // Bulk creates skipped their redraws: one for the batch.
3211
+ try { if (typeof paper !== 'undefined' && paper.view) paper.view.draw(); } catch (_) { /* the frame loop draws */ }
3136
3212
  return { success: errors.length === 0, items: results, count: results.length, errors: errors };
3137
3213
  })();`.trim();
3138
3214
  }
@@ -3613,7 +3689,7 @@ String(JSON.stringify(validated.presetId)) +
3613
3689
  if (validated.targetId)
3614
3690
  return code;
3615
3691
  return code
3616
- .replace(`return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: ${jsLiteral(validated.sourceId)},`, `return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: null,`)
3692
+ .replace(`const __reply = { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: ${jsLiteral(validated.sourceId)},`, `const __reply = { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: null,`)
3617
3693
  .replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
3618
3694
  }
3619
3695
  /**
@@ -4308,15 +4384,17 @@ String(jsLiteral(source)) +
4308
4384
  String(jsLiteral(target)) +
4309
4385
  "];\n if (sourceId && targetId) {\n const params = " +
4310
4386
  String(paramsStr) +
4311
- ";\n // A refused relation is not listed as made (owner rule 1).\n if (app.addRelation(sourceId, targetId, " +
4387
+ ";\n " +
4388
+ String(eventNameResolveJs(type, 'params')) +
4389
+ "\n // A refused relation is not listed as made (owner rule 1).\n if (app.addRelation(sourceId, targetId, " +
4312
4390
  String(jsLiteral(type)) +
4313
- ", params)) {\n results.relations.push({ source: " +
4391
+ ", params)) {\n results.relations.push(Object.assign({ source: " +
4314
4392
  String(jsLiteral(source)) +
4315
4393
  ", target: " +
4316
4394
  String(jsLiteral(target)) +
4317
4395
  ", type: " +
4318
4396
  String(jsLiteral(type)) +
4319
- " });\n const __o = { _: 0" +
4397
+ " }, __ev.eventResolvedTo ? { eventResolvedFrom: __ev.eventResolvedFrom, eventResolvedTo: __ev.eventResolvedTo } : {}));\n if (__ev.warning) (results.optionWarnings = results.optionWarnings || []).push(__ev.warning);\n const __o = { _: 0" +
4320
4398
  String(relationOptionFields(type, params, 'warning')) + " };\n if (__o.warning) (results.optionWarnings = results.optionWarnings || []).push(__o.warning);\n if (__o.ignoredOptions) (results.ignoredOptions = results.ignoredOptions || []).push.apply(results.ignoredOptions, __o.ignoredOptions.map(function(k) { return 'relations[" + String(relationIndex) + "].params.' + k; }));\n } else {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(type))) + ";\n (results.relationsRefused = results.relationsRefused || []).push({ source: " + String(jsLiteral(source)) + ", target: " + String(jsLiteral(target)) + ", type: " + String(jsLiteral(type)) + ", reason: __why || 'the studio refused it' });\n results.success = false;\n results.error = 'the studio refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';\n }\n }\n})();\n"));
4321
4399
  }
4322
4400
  // Apply animations
@@ -5026,7 +5104,19 @@ String(oneLine(operations.length)) +
5026
5104
  "t): create and group ops take a slot each, in op\n // order (O2, mcp-server#29: a group is an item every later op addresses, and it had no slot). The\n // previous batch's ids are kept only to name the right id when a $N reaches\n // past what this batch has made: picking it silently shifted every $N in a\n // batch that also creates (B115 follow-up).\n const itemIds = [];\n const __prevBatchIds = ((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids) || []).slice();\n const __ref = function (n) {\n if (itemIds[n] !== undefined) return itemIds[n];\n const prev = __prevBatchIds[n];\n throw new Error('$' + n + ' is item number ' + n + ' THIS batch made (create " +
5027
5105
  "and group ops are numbered, in op order, from $0), and it has made ' + itemIds.length + ' so far.'\n + (prev ? ' In the previous batch $' + n + ' was ' + prev + ' — pass that id instead.' : ' Put the ops that make it first, or pass the item id.'));\n };\n // A failure names the item, not the \"$1\" that addressed it; the ref as written when the batch never made it.\n const __refName = function (n) { return itemIds[n] !== undefined ? itemIds[n] : '$' + n; };\n const results = [];\n let success = true;\n // A failure's words, never \"undefined\" for a thrown non-Error.\n const _why = (e, fb) => (e && typeof e.message === 'string' && e.message.trim()) ? e.message\n : (typeof e " +
5028
5106
  "=== 'string' && e.trim()) ? e : fb;\n // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything\n // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the\n // result. A studio without lintScene says nothing.\n const __ppLintNow = function () {\n try {\n if (typeof app === 'undefined' || !app || typeof app.lintScene !== 'function') return [];\n const r = app.lintScene({ warningsOnly: true });\n return r && Array.isArray(r.warnings) ? r.warnings : [];\n } catch (e) {\n return [{ code: 'LINT_FAILED', severity: 'info', message: 'the scene lint could no" +
5029
- "t run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];\n }\n };\n // Every failed op as { step, itemId, reason }, beside the per-op results.\n const _failures = () => results.filter((r) => !r.success).map((r) => ({ step: r.step, itemId: r.itemId, reason: r.reason }));\n\n try {\n"
5107
+ "t run: ' + ((e && typeof e.message === 'string' && e.message) || 'no reason given') }];\n }\n };\n // Every failed op as { step, itemId, reason }, beside the per-op results.\n const _failures = () => results.filter((r) => !r.success).map((r) => ({ step: r.step, itemId: r.itemId, reason: r.reason }));\n // ONE UNDO SNAPSHOT FOR THE BATCH, NOT ONE PER OP. Each create saved a full-project snapshot\n // (exportJSON), so a batch on a big scene cost O(items) per op: 200 creates on 2,000 items took\n // 14 s against 0.76 s for the bare creates. History batch mode skips the per-op saves; the batch\n // saves once at the end, as it always did.\n const __hm = app.historyManager && app.his" +
5108
+ "toryManager.beginBatch ? app.historyManager : null;\n if (__hm) __hm.beginBatch();\n let __hmOpen = !!__hm;\n const __hmEnd = () => {\n if (__hmOpen) { __hmOpen = false; __hm.endBatch(false); }\n // Bulk creates skipped their redraws: one for the batch.\n try { if (typeof paper !== 'undefined' && paper.view) paper.view.draw(); } catch (_) { /* the frame loop draws */ }\n };\n\n try {\n"
5109
+
5110
+
5111
+
5112
+
5113
+
5114
+
5115
+
5116
+
5117
+
5118
+
5119
+
5030
5120
 
5031
5121
 
5032
5122
 
@@ -5127,10 +5217,10 @@ String(op.type) +
5127
5217
  ") threw with no message');\n results.push({ index: " +
5128
5218
  String(index) + ", success: false, step: " + String(JSON.stringify(opStep)) + ", itemId: (opError && typeof opError.itemId === 'string') ? opError.itemId : " + String(opItemJs) + ", reason: __r" + String(index) + ", error: __r" + String(index) + " });\n " + String(isAtomic ? 'throw opError;' : 'success = false;') + "\n }\n");
5129
5219
  });
5130
- code += ("\n // Save state\n if (app.historyManager) app.historyManager.saveState();\n\n const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };\n // Name WHICH ops failed on the result itself. A caller reading a failed\n // 60-op batch should not have to scan sixty entries to learn that op 41\n // could not find a preset.\n const _failed = results.filter((r) => !r.success);\n // A batch that created nothing keeps the previous batch's ids on record.\n if (typeof window !== 'undefined' && itemIds.length) {\n window.__pinepaper_batch_item_ids = itemIds;\n }\n return { success, itemIds, results, operationCount: " +
5220
+ code += ("\n // Save state\n __hmEnd();\n if (app.historyManager) app.historyManager.saveState();\n\n const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };\n // Name WHICH ops failed on the result itself. A caller reading a failed\n // 60-op batch should not have to scan sixty entries to learn that op 41\n // could not find a preset.\n const _failed = results.filter((r) => !r.success);\n // A batch that created nothing keeps the previous batch's ids on record.\n if (typeof window !== 'undefined' && itemIds.length) {\n window.__pinepaper_batch_item_ids = itemIds;\n }\n return { success, itemIds, results, operationCount: " +
5131
5221
  String(operations.length) +
5132
- ",\n failedCount: _failed.length,\n ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; '), failures: _failures() } : {}),\n canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };\n } catch (e) {\n // A batch that created nothing keeps the previous batch's ids on record.\n if (typeof window !== 'undefined' && itemIds.length) {\n window.__pinepaper_batch_item_ids = itemIds;\n }\n const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };\n const __fails = _failures();\n const __reason = " +
5133
- "_why(e, 'the batch failed with no message');\n return { success: false, error: __reason, itemIds, results, operationCount: " +
5222
+ ",\n failedCount: _failed.length,\n ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; '), failures: _failures() } : {}),\n canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };\n } catch (e) {\n __hmEnd();\n // A batch that created nothing keeps the previous batch's ids on record.\n if (typeof window !== 'undefined' && itemIds.length) {\n window.__pinepaper_batch_item_ids = itemIds;\n }\n const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };\n const __fails = _failures();\n c" +
5223
+ "onst __reason = _why(e, 'the batch failed with no message');\n return { success: false, error: __reason, itemIds, results, operationCount: " +
5134
5224
  String(operations.length) +
5135
5225
  ",\n failures: __fails.length ? __fails : [{ step: 'batch', itemId: null, reason: __reason }],\n canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }, ...((function () { const l = __ppLintNow(); return l.length ? { __ppLint: l } : {}; })()) };\n }\n})();\n"
5136
5226
 
@@ -5154,6 +5244,8 @@ String(operations.length) +
5154
5244
 
5155
5245
 
5156
5246
 
5247
+
5248
+
5157
5249
 
5158
5250
 
5159
5251
 
@@ -5208,7 +5300,7 @@ String(operations.length) +
5208
5300
  const createProps = withRadiusAxes(opProps);
5209
5301
  const opBrand = { ...opProps };
5210
5302
  delete opProps.noBrand;
5211
- const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined || typeof anyOp.x === 'number' || typeof anyOp.y === 'number'), op.itemType, opBrand));
5303
+ const returning = asBulkCreate(asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined || typeof anyOp.x === 'number' || typeof anyOp.y === 'number'), op.itemType, opBrand)));
5212
5304
  let createCode = `${hasItemSetters(opSetters) ? `\n${itemSettersPreflightJs(opSetters)}` : ''}
5213
5305
  const __created = await (async function() {
5214
5306
  ${returning}
@@ -5319,6 +5411,45 @@ const __members = app.itemRegistry.getAll()
5319
5411
  if (!__members.length) throw new Error('no item is named like ' + ${jsLiteral(op.sourceId)} + ': name items with properties.name');`
5320
5412
  : `const __members = [{ id: ${srcRef} }];
5321
5413
  if (!app.getItemById(__members[0].id)) throw new Error('Source not found: ' + __members[0].id);`;
5414
+ // A COLLECTION RULE IS DECLARED ONCE OVER THE SET (PHASEWAVE32, RINGFORM33). phase_wave, ring_forming
5415
+ // and flock take their members from params.members (or a group's children) and space them by rank in
5416
+ // that list; one relation per item gave each a set of one, so every disc took slot 0 (all at phase 0,
5417
+ // all on the angle-0 point of the ring). Bound once, on the first match, with the matches as members.
5418
+ const rel = String(op.relationType);
5419
+ const relParams = (op.relationOptions ?? {});
5420
+ if (glob && op.update !== true && COLLECTION_RELATIONS.has(rel)) {
5421
+ // ring_forming centres on the source's own bounds by default, and the source is a member that
5422
+ // moves: the centre is fixed at the members' centre as bound, unless a target or center was given.
5423
+ const ringCentre = rel === 'ring_forming' && noTarget && relParams.center === undefined;
5424
+ return ("\n" +
5425
+ String(members) +
5426
+ "\nconst targetId = " +
5427
+ String(noTarget ? 'null' : tgtRef) +
5428
+ ";\nconst target = " +
5429
+ String(noTarget ? 'null' : 'app.getItemById(targetId)') +
5430
+ ";\n" +
5431
+ String(noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n") +
5432
+ "const __ids = __members.map((m) => m.id);\nconst __p = Object.assign({}, " +
5433
+ String(relOpts) +
5434
+ String(relParams.members === undefined ? ', { members: { ids: __ids } }' : '') +
5435
+ ");\n" +
5436
+ String(ringCentre ? `{
5437
+ let __b = null;
5438
+ __ids.forEach((id) => { const it = app.getItemById(id); if (it && it.bounds) __b = __b ? __b.unite(it.bounds) : it.bounds.clone(); });
5439
+ if (__b) __p.center = { x: __b.center.x, y: __b.center.y };
5440
+ }
5441
+ ` : '') +
5442
+ "const __src = app.getItemById(__ids[0]);\nconst __existing = app.relationRegistry && app.relationRegistry.getAssociations\n && app.relationRegistry.getAssociations(__ids[0], " +
5443
+ String(type) +
5444
+ ").some((a) => a.target === targetId || (targetId === null && a.target == null));\nconst ok = (__existing && typeof app.modifyRelationParams === 'function')\n ? (app.modifyRelationParams(__ids[0], targetId, " +
5445
+ String(type) +
5446
+ ", __p) || (targetId === null && app.modifyRelationParams(__ids[0], undefined, " +
5447
+ String(type) +
5448
+ ", __p)))\n : app.addRelation(__src, target, " +
5449
+ String(type) +
5450
+ ", __p);\nif (!ok) {\n const __why = " +
5451
+ String(relationRefusalReasonJs(type)) + ";\n return { success: false, relationType: " + String(type) + ", sourceIds: [__ids[0]], error: 'the studio refused the ' + " + String(type) + " + ' relation over ' + __ids.length + ' members — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { success: true, relationType: " + String(type) + ", bound: [__ids[0]], members: __ids, count: __ids.length, targetId" + String(ringCentre ? ', center: __p.center' : '') + String(relationOptionFields(op.relationType, op.relationOptions, 'optionWarning', ['members'])) + " };\n");
5452
+ }
5322
5453
  return ("\n" +
5323
5454
  String(stepGuard) +
5324
5455
  String(members) +
@@ -5360,14 +5491,18 @@ String(noTarget ? 'null' : tgtRef) +
5360
5491
  String(noTarget ? 'null' : 'app.getItemById(targetId)') +
5361
5492
  ";\nif (!source) throw new Error('Source not found: ' + sourceId);\n" +
5362
5493
  String(noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n") +
5363
- "const relationId = app.addRelation(source, target, " +
5364
- String(JSON.stringify(String(op.relationType))) +
5365
- ", " +
5494
+ "const __relOpts = " +
5366
5495
  String(relOpts) +
5367
- ");\n// NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false\n// when it refuses — spring_follow \"not loaded yet\" — and this op returned\n// { relationId: false } with nothing marking it failed, so the batch said\n// success over a relation that does not exist.\nif (!relationId) {\n const __why = " +
5496
+ ";\n" +
5497
+ String(eventNameResolveJs(op.relationType, '__relOpts')) +
5498
+ "\nconst relationId = app.addRelation(source, target, " +
5499
+ String(JSON.stringify(String(op.relationType))) +
5500
+ ", __relOpts);\n// NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false\n// when it refuses — spring_follow \"not loaded yet\" — and this op returned\n// { relationId: false } with nothing marking it failed, so the batch said\n// success over a relation that does not exist.\nif (!relationId) {\n const __why = " +
5368
5501
  String(relationRefusalReasonJs(jsLiteral(op.relationType))) +
5369
5502
  ";\n return { success: false, relationId: false, sourceId, targetId, relationType: " +
5370
- String(JSON.stringify(op.relationType)) + ",\n error: 'the studio refused the ' + " + String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String(relationOptionFields(op.relationType, op.relationOptions, 'warning')) + " };\n");
5503
+ String(JSON.stringify(op.relationType)) +
5504
+ ",\n error: 'the studio refused the ' + " +
5505
+ String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nconst __reply = { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String(relationOptionFields(op.relationType, op.relationOptions, 'warning')) + " };\n" + String(EV_MERGE_JS('__reply')) + "\nreturn __reply;\n");
5371
5506
  case 'delete':
5372
5507
  const delRef = batchRef(op.itemId);
5373
5508
  return `
@@ -5598,6 +5733,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5598
5733
  generateAgentExport(input) {
5599
5734
  const validated = AgentExportInputSchema.parse(input);
5600
5735
  const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, audioCodec, transparent, alphaQuantizer, deterministic: askedDeterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
5736
+ const regionSrc = region ? regionCoverCrop(region) : undefined;
5601
5737
  // Every video is deterministic unless the caller opts out (owner, 2026-11-07), so every receipt says whether its file repeats.
5602
5738
  const deterministic = askedDeterministic ?? (format === 'mp4' || format === 'webm');
5603
5739
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
@@ -5924,34 +6060,42 @@ String(stillTime === undefined ? ` if (['png', 'jpg', 'webp', 'svg', 'pdf'].ind
5924
6060
  ` : '') +
5925
6061
  " // A STILL SHOWS WHAT IS ALIVE AT ITS MOMENT. Lifetimes that fall back to the\n // MCP's frame callback are applied by the frame loop, and a seek does not run\n // it — so a png at time 0.8 s drew nothing staged at 0.8 s, and an item past\n // its ttl still showed (retest). The visibility pass runs here for stills.\n if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1) {\n const __L = globalThis.__ppMcp;\n if (__L && __L.lifetimeApp === app && typeof __L.applyLifetimes === 'function') {\n try { __L.applyLifetimes(__stillT !== null ? __stillT : (typeof app.playbackTime === 'number' ? app.playbackTime : 0)); } catch (_) { /* the still renders either way */ }\n " +
5926
6062
  " }\n }\n // The engine's report on the audio track of THIS export (FxTool b41860aa:\n // encoder delay compensated, padding past the picture dropped). Cleared\n // first so a report left by an earlier export is never read as this one's.\n const __vx = app.exportEngine && app.exportEngine.videoExporter;\n if (__vx && 'lastAudioReport' in __vx) __vx.lastAudioReport = null;\n if (__vx && 'lastVideoReport' in __vx) __vx.lastVideoReport = null;\n // A STILL AT A CHOSEN MOMENT (round 7 X, 1.57). Without this a png is\n // whatever frame the playhead is on — two identical builds gave PNGs that\n // differed in the animated streak while their MP4s were byte-identical.\n // Seek, render," +
5927
- " and put the playhead back afterwards.\n const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;\n if (__stillT !== null) {\n if (typeof app.setPlaybackTime !== 'function') {\n return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen time.' };\n }\n app.setPlaybackTime(__stillT);\n if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();\n }\n\n try {\n // jpg / webp render as png, then re-encode below.\n switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {\n case 'svg':\n const svgString " +
5928
- "= app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();\n result = {\n success: true,\n platform,\n format: 'svg',\n data: svgString,\n mimeType: 'image/svg+xml',\n size: new Blob([svgString]).size,\n hasAnimations: svgString.includes('<animate') || svgString.includes('@keyframes')\n };\n break;\n\n case 'png':\n " +
5929
- String(region ? ("// A REGION, not the whole board — carousel slices, crops. renderRegionToDataURL\n // re-renders that world rect offscreen without touching the live view;\n // it was reachable only from custom code. A differing output aspect is\n // covered by the engine, not stretched.\n if (typeof app.renderRegionToDataURL !== 'function') {\n result = { success: false, platform, format: 'png', error: 'app.renderRegionToDataURL unavailable — update PinePaper Studio to export a region.' };\n break;\n }\n {\n const rw = " +
6063
+ " and put the playhead back afterwards.\n const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;\n // A PDF STILL HOLDS ITS MOMENT (PDFEXP34 a). exportPDF awaits its PDF library\n // and then captures the live view, so a playing timeline ran on past the seek\n // and the page showed about t=1.3 s whatever time asked for. The timeline is\n // paused for the PDF and resumed after it.\n let __pausedForStill = false;\n let __pdfEvaluated = false;\n if (__stillT !== null) {\n if (typeof app.setPlaybackTime !== 'function') {\n return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen tim" +
6064
+ "e.' };\n }\n if (format === 'pdf' && app.isPlayingKeyframes && !app.isAnimationPaused) {\n try { app.pauseKeyframeTimeline(); __pausedForStill = true; } catch (_) { /* the seek below still applies */ }\n }\n app.setPlaybackTime(__stillT);\n if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();\n }\n\n try {\n // jpg / webp render as png, then re-encode below.\n switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {\n case 'svg':\n const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();\n result = {\n success: true,\n pla" +
6065
+ "tform,\n format: 'svg',\n data: svgString,\n mimeType: 'image/svg+xml',\n size: new Blob([svgString]).size,\n hasAnimations: svgString.includes('<animate') || svgString.includes('@keyframes')\n };\n break;\n\n case 'png':\n " +
6066
+ String(region ? ("// A REGION, not the whole board — carousel slices, crops. renderRegionToDataURL\n // re-renders that world rect offscreen without touching the live view;\n // it was reachable only from custom code. A differing output aspect is\n // cropped here first (regionCoverCrop), so the engine only scales.\n if (typeof app.renderRegionToDataURL !== 'function') {\n result = { success: false, platform, format: 'png', error: 'app.renderRegionToDataURL unavailable — update PinePaper Studio to export a region.' };\n break;\n }\n {\n const rw = " +
5930
6067
  String(region.outputWidth ?? Math.round(region.width)) +
5931
6068
  ", rh = " +
5932
6069
  String(region.outputHeight ?? Math.round(region.height)) +
5933
6070
  ";\n // OWNERSHIP BY CENTRE (1.78, FxTool 78ec5916): excludeForeign hides, for\n // this render, a top-level item that reaches in but is centred outside —\n // a neighbouring card's overflowing headline. Older studios ignore the\n // fourth argument, and leave lastRegionExcluded unset.\n if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;\n // A video in the region is waited for at this time (round 12, 12.5):\n // the render drew before the clip's seek landed.\n if (typeof app.awaitMediaAt === 'function') await app.awaitMediaAt(__stillT !== null ? __stillT : (typeof app.playbackTime === 'number' ? app.playba" +
5934
6071
  "ckTime : 0));\n const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(" +
5935
- String(region.x) +
6072
+ String(regionSrc.x) +
5936
6073
  ", " +
5937
- String(region.y) +
6074
+ String(regionSrc.y) +
5938
6075
  ", " +
5939
- String(region.width) +
6076
+ String(regionSrc.width) +
5940
6077
  ", " +
5941
- String(region.height) +
6078
+ String(regionSrc.height) +
5942
6079
  "), rw, rh" +
5943
6080
  String(region.excludeForeign === false ? '' : ', { excludeForeign: true }') +
5944
6081
  ");\n const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;\n // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet\n // recipe — one big canvas, one region per card — text that overflows\n // its cell spills into the next card's export (up to 5,826 foreign px),\n // silently. A region cannot know which card an item belongs to, so it\n // cannot clip per card; it can name every item that is only partly\n // inside it, which is exactly the overflow in either direction.\n const __rx = " +
5945
- String(region.x) +
6082
+ String(regionSrc.x) +
5946
6083
  ", __ry = " +
5947
- String(region.y) +
6084
+ String(regionSrc.y) +
5948
6085
  ", __rr = " +
5949
- String(region.x + region.width) +
6086
+ String(regionSrc.x + regionSrc.width) +
5950
6087
  ", __rb = " +
5951
- String(region.y + region.height) +
6088
+ String(regionSrc.y + regionSrc.height) +
5952
6089
  ";\n const crossing = [], coveringExcluded = [];\n if (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {\n app.itemRegistry.getAll().forEach(function(e) {\n const b = e && e.item && e.item.visible !== false && e.item.bounds;\n if (!b || !(b.width > 0)) return;\n const r = b.x + b.width, bt = b.y + b.height;\n const intersects = b.x < __rr && r > __rx && b.y < __rb && bt > __ry;\n const inside = b.x >= __rx - 0.5 && r <= __rr + 0.5 && b.y >= __ry - 0.5 && bt <= __rb + 0.5;\n const covers = b.x <= __rx && r >= __rr && b.y <= __ry && bt >= __rb; // a full-bleed backdrop" +
5953
6090
  " is not overflow\n if (intersects && !inside && !covers && crossing.length < 20) crossing.push(e.itemId);\n if (covers && __excluded && (__excluded.indexOf(e.itemId) >= 0 || (e.item.data && __excluded.indexOf(e.item.data.id) >= 0))) coveringExcluded.push(e.itemId);\n });\n }\n result = { success: true, platform, format: 'png', data: regionUrl, mimeType: 'image/png',\n size: Math.round(String(regionUrl).length * 0.75), dimensions: { width: rw, height: rh },\n region: " +
5954
6091
  String(JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height })) +
6092
+ String(regionSrc.cropped ? `,
6093
+ // A DIFFERENT OUTPUT ASPECT IS A CROP (REGION34). The studio grew the box to
6094
+ // the output's aspect instead, drawing content from outside the region and
6095
+ // black past the canvas edge. The server now trims the region about its
6096
+ // centre to the output's aspect, so the studio is only asked to scale.
6097
+ croppedTo: ${JSON.stringify({ x: regionSrc.x, y: regionSrc.y, width: regionSrc.width, height: regionSrc.height })},
6098
+ cropNote: ${JSON.stringify('the output aspect differs from the region\'s, so the region was cropped about its centre to fit (croppedTo); nothing outside the region is drawn.')}` : '') +
5955
6099
  " };\n if (__excluded && __excluded.length) {\n result.excludedItems = __excluded;\n result.excludedNote = 'centred outside this region, so treated as belonging to a neighbouring region and left out of this image.';\n }\n if (coveringExcluded.length) {\n result.warning = 'backdrop item(s) ' + coveringExcluded.join(', ') + ' cover this whole region but are centred outside it, so they were left out as foreign — this image has no background from them. Re-export with region.excludeForeign: false, or give each card its own backdrop.';\n }\n if (crossing.length) {\n result.crossingItems = crossing;\n " +
5956
6100
  " result.warning = (result.warning ? result.warning + ' ' : '') + crossing.length + ' item(s) are only partly inside this region, so part of them is cut off here or spills into a neighbouring region: ' + crossing.join(', ') + '. In a sheet of cards this is text overflowing its card — shorten it, shrink it, or wrap it.';\n }\n }\n break;"
5957
6101
 
@@ -5975,6 +6119,11 @@ String(JSON.stringify({ x: region.x, y: region.y, width: region.width, height: r
5975
6119
 
5976
6120
 
5977
6121
 
6122
+
6123
+
6124
+
6125
+
6126
+
5978
6127
 
5979
6128
 
5980
6129
 
@@ -6013,9 +6162,9 @@ String(encodeOptsJs ? `
6013
6162
  // Broadcast / bitrate. A store that rebuilds its options without
6014
6163
  // them is caught by the video report below, not assumed.
6015
6164
  ${encodeOptsJs.slice(2)},` : '') +
6016
- "\n });\n if (!stored || stored.ok === false) {\n result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why' };\n } else {\n let chunkBytes = 4 * 1024 * 1024;\n try {\n const declared = app.exportEngine.constructor && app.exportEngine.constructor.EXPORT_CHUNK_BYTES;\n if (typeof declared === 'number' && declared > 0) chunkBytes = declared;\n } catch (_) { /* keep the default */ }\n result = {\n success: true, platform, framing,\n // Trust the container the store reports over the on" +
6017
- "e asked for:\n // mp4 falls back to WebM without WebCodecs, and WebM bytes named\n // .mp4 fail in container-validating players.\n format: stored.format || format,\n retained: true,\n exportId: stored.exportId,\n mimeType: stored.mimeType || videoMimeType,\n size: stored.size,\n chunkBytes: chunkBytes,\n dimensions: cameraDims || dimensions,\n };\n }\n break;\n }\n\n // NO EXPORT STORE ON THIS STUDIO, or gif. Both remaining failures are\n // named rather than left to blow up: a streamed export hands back a\n // marker " +
6018
- "instead of a Blob and FileReader throws on it, and an export\n // past the inline ceiling used to die somewhere in the base64 rather\n // than say so. The ceiling sits above what this path could already do\n // (60 s at 8 Mbps is roughly 60 MB) so nothing that works today starts\n // refusing.\n const INLINE_MAX_BYTES = " +
6165
+ "\n });\n if (!stored || stored.ok === false) {\n result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why',\n // The studio's code for a refusal (audio_codec_unavailable, B45), for a caller to branch on.\n ...(stored && typeof stored.code === 'string' ? { code: stored.code } : {}) };\n } else {\n let chunkBytes = 4 * 1024 * 1024;\n try {\n const declared = app.exportEngine.constructor && app.exportEngine.constructor.EXPORT_CHUNK_BYTES;\n if (typeof declared === 'number' && declared > 0) chunkBytes = d" +
6166
+ "eclared;\n } catch (_) { /* keep the default */ }\n result = {\n success: true, platform, framing,\n // Trust the container the store reports over the one asked for:\n // mp4 falls back to WebM without WebCodecs, and WebM bytes named\n // .mp4 fail in container-validating players.\n format: stored.format || format,\n retained: true,\n exportId: stored.exportId,\n mimeType: stored.mimeType || videoMimeType,\n size: stored.size,\n chunkBytes: chunkBytes,\n dimensions: cameraDims || dimensions,\n };\n }\n " +
6167
+ " break;\n }\n\n // NO EXPORT STORE ON THIS STUDIO, or gif. Both remaining failures are\n // named rather than left to blow up: a streamed export hands back a\n // marker instead of a Blob and FileReader throws on it, and an export\n // past the inline ceiling used to die somewhere in the base64 rather\n // than say so. The ceiling sits above what this path could already do\n // (60 s at 8 Mbps is roughly 60 MB) so nothing that works today starts\n // refusing.\n const INLINE_MAX_BYTES = " +
6019
6168
  String(INLINE_MAX_BYTES) +
6020
6169
  ";\n const mb = (n) => (n / (1024 * 1024)).toFixed(1);\n const deliver = async (raw) => {\n // Normalise first: a {blob, …} record would otherwise be reported as\n // \"streamed to a file instead of returning bytes\", which is a true\n // sentence about the wrong problem.\n const blob = asBlob(raw) || raw;\n if (!blob || typeof blob.size !== 'number' || typeof blob.slice !== 'function') {\n return {\n success: false, platform, format,\n error: 'this export streamed to a file instead of returning bytes, and this studio has no app.exportEngine.exportToStore to page it back from — update FxTool for " +
6021
6170
  "long-form export',\n };\n }\n if (blob.size > INLINE_MAX_BYTES) {\n return {\n success: false, platform, format, size: blob.size,\n error: 'export is ' + mb(blob.size) + ' MB, over the ' + mb(INLINE_MAX_BYTES)\n + ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',\n };\n }\n // The container the file IS: an encoder that cannot do H.264 at this\n // size writes VP9 WebM (round 12: mp4 at 12000 px came back WebM),\n // and naming it .mp4 breaks players. Said below in fidelity.\n " +
@@ -6034,21 +6183,42 @@ String(gifLoop !== undefined ? `// A LOOP COUNT (8.30): _quickExportVideo rebuil
6034
6183
  "\n if (videoResult && videoResult.blob) {\n result = await deliver(videoResult.blob);\n } else {\n result = { success: false, error: format.toUpperCase() + ' export returned no data' };\n }\n " +
6035
6184
  String(maxBytes !== undefined ? ("// A BYTE BUDGET (round 9 GG, 8.31 — email wants <= 1 MB). gif.js has no\n // colour-count knob, so size is the lever: GIF bytes scale roughly with\n // pixel count, so the frame is shrunk by sqrt(budget / size) with a\n // margin and re-encoded, at most twice. Every attempt is reported.\n if (format === 'gif' && result && result.success && result.size > " +
6036
6185
  String(maxBytes) +
6037
- ") {\n const attempts = [{ width: dimensions.width, height: dimensions.height, bytes: result.size }];\n let w = dimensions.width, h = dimensions.height;\n for (let k = 0; k < 2 && result.success && result.size > " +
6186
+ ") {\n // THE FILE'S OWN SIZE, from its header (GIFMAX34). The first GIF comes from _quickExportVideo, which\n // may not render at the canvas size, so shrinking \"the canvas\" could ask for a LARGER frame: a\n // 20 kB budget turned a 74 kB GIF into a 170 kB one. Each attempt is measured, a retry never asks for\n // more pixels than the file it replaces, and the smallest file wins.\n const __gifDims = async (b) => {\n try {\n const a = new Uint8Array(await (asBlob(b) || b).slice(0, 10).arrayBuffer());\n if (a[0] === 0x47 && a[1] === 0x49 && a[2] === 0x46) return { width: a[6] | (a[7] << 8), he" +
6187
+ "ight: a[8] | (a[9] << 8) };\n } catch (_) { /* the canvas size stands in */ }\n return null;\n };\n const __d0 = (videoResult && videoResult.blob && await __gifDims(videoResult.blob)) || { width: dimensions.width, height: dimensions.height };\n const attempts = [{ width: __d0.width, height: __d0.height, bytes: result.size }];\n let best = result, bestDims = __d0;\n let w = __d0.width, h = __d0.height;\n for (let k = 0; k < 2 && best.size > " +
6038
6188
  String(maxBytes) +
6039
6189
  "; k++) {\n const f = Math.sqrt(" +
6040
6190
  String(maxBytes) +
6041
- " / result.size) * 0.9;\n w = Math.max(16, Math.round(w * f / 2) * 2); h = Math.max(16, Math.round(h * f / 2) * 2);\n // videoExporter.export, not _quickExportVideo: the latter rebuilds\n // its settings without width / height, so a \"smaller\" retry\n // through it came out the same size.\n if (!app.exportEngine.videoExporter || typeof app.exportEngine.videoExporter.export !== 'function') break;\n const again = await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', width: w, height: h }" +
6191
+ " / best.size) * 0.9;\n w = Math.max(16, Math.min(bestDims.width - 2, Math.round(bestDims.width * f / 2) * 2));\n h = Math.max(16, Math.min(bestDims.height - 2, Math.round(bestDims.height * f / 2) * 2));\n // videoExporter.export, not _quickExportVideo: the latter rebuilds\n // its settings without width / height, so a \"smaller\" retry\n // through it came out the same size.\n if (!app.exportEngine.videoExporter || typeof app.exportEngine.videoExporter.export !== 'function') break;\n const again = await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', width:" +
6192
+ " w, height: h }" +
6042
6193
  String(gifLoop !== undefined ? `, { loop: ${JSON.stringify(gifLoop)} }` : '') +
6043
- "));\n if (!again) break;\n result = await deliver(again);\n attempts.push({ width: w, height: h, bytes: result.size });\n }\n result.budget = { maxBytes: " +
6194
+ "));\n if (!again) break;\n const r2 = await deliver(again);\n const d2 = (await __gifDims(again)) || { width: w, height: h };\n attempts.push({ width: d2.width, height: d2.height, bytes: r2.size });\n if (r2.success && r2.size < best.size) { best = r2; bestDims = d2; }\n }\n result = best;\n result.budget = { maxBytes: " +
6044
6195
  String(maxBytes) +
6045
6196
  ", met: !!(result.success && result.size <= " +
6046
6197
  String(maxBytes) +
6047
- "), attempts: attempts };\n if (result.success && result.size > " +
6198
+ "), attempts: attempts };\n // The file delivered is said by its own size, met or not (GIFMAX34: a missed budget kept the\n // requested dimensions in the reply).\n if (result.success) result.dimensions = { width: bestDims.width, height: bestDims.height };\n if (result.success && (bestDims.width !== dimensions.width || bestDims.height !== dimensions.height)) {\n result.downscaled = { from: { width: dimensions.width, height: dimensions.height }, to: { width: bestDims.width, height: bestDims.height }, reason: bestDims === __d0 ? 'studio' : 'maxBytes' };\n }\n if (result.success && result.size > " +
6048
6199
  String(maxBytes) +
6049
- ") {\n result.warning = 'still ' + result.size + ' bytes after shrinking to ' + w + 'x' + h + ' — over the ' + " +
6200
+ ") {\n result.warning = 'still ' + result.size + ' bytes at ' + bestDims.width + 'x' + bestDims.height + ', the smallest of ' + attempts.length + ' tries — over the ' + " +
6050
6201
  String(maxBytes) +
6051
- " + '-byte budget. Lower fps, shorten duration, or use quality \"draft\".';\n } else if (result.success) {\n result.dimensions = { width: w, height: h };\n }\n }"
6202
+ " + '-byte budget. Lower fps, shorten duration, or use quality \"draft\".';\n }\n }"
6203
+
6204
+
6205
+
6206
+
6207
+
6208
+
6209
+
6210
+
6211
+
6212
+
6213
+
6214
+
6215
+
6216
+
6217
+
6218
+
6219
+
6220
+
6221
+
6052
6222
 
6053
6223
 
6054
6224
 
@@ -6175,7 +6345,8 @@ String(pdfOpts.searchableText !== false) +
6175
6345
 
6176
6346
 
6177
6347
  ) : '') +
6178
- "\n if (app.exportEngine && app.exportEngine.exportPDF) {\n // Print options reach the engine (they were engine-only before), and\n // download:false, because the tool delivers the bytes itself.\n // lastPdfReport is a report PROPERTY (9917cfa5), read through the\n // engine object like the exporter's audio / video reports, and only\n // when present.\n const __pee = app.exportEngine;\n if ('lastPdfReport' in __pee) __pee.lastPdfReport = null;\n const pdfOut = await app.exportEngine.exportPDF({\n dpi: " +
6348
+ "\n if (app.exportEngine && app.exportEngine.exportPDF) {\n // Print options reach the engine (they were engine-only before), and\n // download:false, because the tool delivers the bytes itself.\n // lastPdfReport is a report PROPERTY (9917cfa5), read through the\n // engine object like the exporter's audio / video reports, and only\n // when present.\n const __pee = app.exportEngine;\n if ('lastPdfReport' in __pee) __pee.lastPdfReport = null;\n // exportPDF does not evaluate the scene at the playhead as exportPNG\n // does (relations, timed events), so the same engine step runs here,\n // and w" +
6349
+ "hat its timed events changed is put back after the page.\n if (__stillT !== null && typeof __pee._evaluateStillAt === 'function') {\n try { __pee._evaluateStillAt(__stillT); __pdfEvaluated = true; } catch (_) { /* the page renders either way */ }\n if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();\n }\n const pdfOut = await app.exportEngine.exportPDF({\n dpi: " +
6179
6350
  String(pdfOpts?.dpi ?? 'settings.dpi') +
6180
6351
  ",\n download: false," +
6181
6352
  String(pdfOpts?.searchableText === false ? '\n searchableText: false,' : '') +
@@ -6184,18 +6355,20 @@ String(pdfOpts?.orientation ? `\n orientation: ${JSON.stringify(pdfOp
6184
6355
  String(pdfOpts?.bleed !== undefined ? `\n includeBleed: ${pdfOpts.bleed > 0}, bleed: ${pdfOpts.bleed},` : '') +
6185
6356
  String(pdfOpts?.trimMarks !== undefined ? `\n trimMarks: ${pdfOpts.trimMarks},` : '') +
6186
6357
  "\n });\n const blob = asBlob(pdfOut);\n if (!blob) {\n const got = pdfOut === null ? 'null'\n : typeof pdfOut === 'object' ? 'an object with keys [' + Object.keys(pdfOut).join(', ') + ']'\n : typeof pdfOut;\n result = { success: false, platform, format: 'pdf',\n error: 'the PDF export did not produce a file: expected a Blob, got ' + got + '.' };\n break;\n }\n const dataUrl = await blobToDataUrl(blob);\n result = {\n success: true, platform, format: 'pdf', data: dataUrl,\n mimeType: 'application/pdf', size: blob.size, dpi: settings.dpi,\n " +
6187
- " // The record carries the page geometry the engine chose; it is the\n // only place a caller can learn it.\n ...(pdfOut && pdfOut.width ? { dimensions: { width: pdfOut.width, height: pdfOut.height } } : {}),\n ...(pdfOut && pdfOut.format ? { paperFormat: pdfOut.format } : {})," +
6188
- String(pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && ((pdfOpts.bleed ?? 0) > 0 || pdfOpts.trimMarks) ? `
6189
- // Bleed and trim marks on a NAMED paper size: the studio keeps the
6190
- // page at that size and centres the art, so neither appears (prod
6191
- // retest: MediaBox = TrimBox = A4, no marks). Said, not silent.
6192
- warnings: [${JSON.stringify(`bleed / trimMarks were passed with paperFormat '${pdfOpts.paperFormat}', and this studio applies them only to paperFormat 'custom' (page = canvas + bleed): the page stayed ${pdfOpts.paperFormat} with no bleed and no marks. For print with bleed, size the canvas with a PRINT preset (agent_start_job canvasPreset 'print-a4' / 'print-letter' / 'print-a4-landscape' / 'print-letter-landscape', which carry 300 dpi — a plain 2480x3508 canvas is read at 96 dpi and comes out ~656 mm wide) and pass paperFormat 'custom'.`)}],` : '') +
6193
- "\n };\n } else {\n result = { success: false, error: 'PDF export failed' };\n }\n break;\n\n default:\n result = { success: false, error: 'Unknown format: ' + format };\n }\n } catch (e) {\n result = { success: false, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('the ' + format + ' export threw with no message'), platform, format };\n }\n\n // One site, after every branch has set its result: a successful export says\n // what this scene lost to this format, rather than the caller finding out by\n // opening the file. Only when there is something to say — see fidelity().\n // RE-EN" +
6194
- "CODE A STILL FOR A BYTE BUDGET. The png path rendered it (whole board\n // or region); the browser's own encoder makes jpg / webp at the tier's\n // compression. jpg has no alpha, so it is flattened onto the scene background\n // (white when there is none) rather than left to the encoder's black.\n if (result && result.success && (format === 'jpg' || format === 'webp') && typeof result.data === 'string') {\n const mime = format === 'jpg' ? 'image/jpeg' : 'image/webp';\n try {\n const encoded = await new Promise(function(resolve, reject) {\n const img = new Image();\n img.onload = function() {\n const c = document.createElement('canvas');\n c.w" +
6195
- "idth = img.naturalWidth; c.height = img.naturalHeight;\n const x = c.getContext('2d');\n // The scene's own background when one is set — white text on a\n // transparent board vanished into a white flatten. White otherwise.\n if (mime === 'image/jpeg') {\n const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;\n x.fillStyle = (bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg)) ? bg : '#ffffff';\n x.fillRect(0, 0, c.width, c.height);\n }\n x.drawImage(img, 0, 0);\n resolve(c.toDataURL(mime, settings.compression));\n };\n img.onerror = function() " +
6196
- "{ reject(new Error('the rendered frame could not be decoded for re-encoding')); };\n img.src = result.data;\n });\n if (String(encoded).indexOf('data:' + mime) !== 0) {\n result = { success: false, platform, format, error: 'this browser cannot encode ' + format + ' (it returned ' + String(encoded).slice(5, 20) + '…).' };\n } else {\n result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });\n }\n } catch (e) {\n result = { success: false, platform, format, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string" +
6197
- "' && e) || ('re-encoding the still as ' + format + ' failed with no message') };\n }\n }\n\n if (result && result.success) Object.assign(result, fidelity(format));\n // MOTION FROM RELATIONS IS MOTION. The studio's check counts keyframe tracks\n // and simple animations only, so an orbit (or attached_to a moving item, a\n // generator, the camera) exported with animated:false while the frames moved,\n // and callers rebuilt the scene with keyframes. When it says false, the\n // frames decide: four captures across the export. The times are uneven so a\n // loop cannot land on whole periods and look still. After the export, so it\n // cannot touch the file. A capture leaves the p" +
6198
- "ose of the last time it drew,\n // so the samples end on 0 again: the canvas is left at rest, as found.\n if (result && result.success && ['mp4', 'webm', 'gif', 'apng'].indexOf(format) !== -1\n && result.fidelity && result.fidelity.checked && result.fidelity.checked.animated === false\n && typeof app.captureFramesAt === 'function') {\n try {\n const __mt = [0, 0.17, 0.43, 0.71].map((k) => Math.round(k * " +
6358
+ " // The record carries the page geometry the engine chose; it is the\n // only place a caller can learn it.\n ...(pdfOut && pdfOut.width ? { dimensions: { width: pdfOut.width, height: pdfOut.height } } : {}),\n ...(pdfOut && pdfOut.format ? { paperFormat: pdfOut.format } : {}),\n // What the page carries for print (FxTool #347): marks drawn, bleed applied, page and trim boxes.\n ...(pdfOut && pdfOut.print && typeof pdfOut.print === 'object' ? { print: pdfOut.print } : {})," +
6359
+ String(pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && pdfOpts.trimMarks ? `
6360
+ // TRIM MARKS ON A NAMED SHEET. Bleed is applied there (page = sheet + 2 x bleed, TrimBox = sheet), but
6361
+ // a studio before FxTool #347 draws no marks on a named sheet. Said unless the file's own record
6362
+ // says it drew them.
6363
+ ...((pdfOut && pdfOut.print && pdfOut.print.trimMarksDrawn) || (__pee.lastPdfReport && __pee.lastPdfReport.trimMarksDrawn) ? {} : {
6364
+ warnings: [${JSON.stringify(`trimMarks were passed with paperFormat '${pdfOpts.paperFormat}', and this studio draws trim marks only with paperFormat 'custom' (the page is the canvas plus bleed), so this page has none. Any bleed was applied. For marks, pass paperFormat 'custom' on a canvas sized by a print preset (canvasPreset 'print-a4' etc., 300 dpi).`)}],
6365
+ }),` : '') +
6366
+ "\n };\n } else {\n result = { success: false, error: 'PDF export failed' };\n }\n break;\n\n default:\n result = { success: false, error: 'Unknown format: ' + format };\n }\n } catch (e) {\n result = { success: false, error: (e && typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('the ' + format + ' export threw with no message'), platform, format,\n ...(e && typeof e.code === 'string' ? { code: e.code } : {}) };\n }\n\n // One site, after every branch has set its result: a successful export says\n // what this scene lost to this format, rather than the caller finding out by\n // opening the fi" +
6367
+ "le. Only when there is something to say — see fidelity().\n // RE-ENCODE A STILL FOR A BYTE BUDGET. The png path rendered it (whole board\n // or region); the browser's own encoder makes jpg / webp at the tier's\n // compression. jpg has no alpha, so it is flattened onto the scene background\n // (white when there is none) rather than left to the encoder's black.\n if (result && result.success && (format === 'jpg' || format === 'webp') && typeof result.data === 'string') {\n const mime = format === 'jpg' ? 'image/jpeg' : 'image/webp';\n try {\n const encoded = await new Promise(function(resolve, reject) {\n const img = new Image();\n img.onload = function() {" +
6368
+ "\n const c = document.createElement('canvas');\n c.width = img.naturalWidth; c.height = img.naturalHeight;\n const x = c.getContext('2d');\n // The scene's own background when one is set — white text on a\n // transparent board vanished into a white flatten. White otherwise.\n if (mime === 'image/jpeg') {\n const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;\n x.fillStyle = (bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg)) ? bg : '#ffffff';\n x.fillRect(0, 0, c.width, c.height);\n }\n x.drawImage(img, 0, 0);\n resolve(c.toDataURL(mime, " +
6369
+ "settings.compression));\n };\n img.onerror = function() { reject(new Error('the rendered frame could not be decoded for re-encoding')); };\n img.src = result.data;\n });\n if (String(encoded).indexOf('data:' + mime) !== 0) {\n result = { success: false, platform, format, error: 'this browser cannot encode ' + format + ' (it returned ' + String(encoded).slice(5, 20) + '…).' };\n } else {\n result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });\n }\n } catch (e) {\n result = { success: false, platform, format, error: (e &" +
6370
+ "& typeof e.message === 'string' && e.message) || (typeof e === 'string' && e) || ('re-encoding the still as ' + format + ' failed with no message') };\n }\n }\n\n if (result && result.success) Object.assign(result, fidelity(format));\n // MOTION FROM RELATIONS IS MOTION. The studio's check counts keyframe tracks\n // and simple animations only, so an orbit (or attached_to a moving item, a\n // generator, the camera) exported with animated:false while the frames moved,\n // and callers rebuilt the scene with keyframes. When it says false, the\n // frames decide: four captures across the export. The times are uneven so a\n // loop cannot land on whole periods and look still. After" +
6371
+ " the export, so it\n // cannot touch the file. A capture leaves the pose of the last time it drew,\n // so the samples end on 0 again: the canvas is left at rest, as found.\n if (result && result.success && ['mp4', 'webm', 'gif', 'apng'].indexOf(format) !== -1\n && result.fidelity && result.fidelity.checked && result.fidelity.checked.animated === false\n && typeof app.captureFramesAt === 'function') {\n try {\n const __mt = [0, 0.17, 0.43, 0.71].map((k) => Math.round(k * " +
6199
6372
  String(videoDuration) +
6200
6373
  " * 1000) / 1000);\n const __mcap = typeof app.captureFramesAtAsync === 'function' ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);\n const __mf = await __mcap(__mt.concat([0]), { seed: 0, capture: (c) => (typeof app.captureFrameDataURL === 'function' ? app.captureFrameDataURL() : (c && c.toDataURL ? c.toDataURL() : '')) });\n const __mu = new Set((__mf || []).filter((u) => typeof u === 'string' && u.length > 0));\n if (__mu.size > 1) {\n result.fidelity.checked.animated = true;\n result.fidelity.checked.motion = 'frames differ across the export (sampled at ' + __mt.join(', ') + ' s): the motion comes from relations, generators or the c" +
6201
6374
  "amera, not keyframe tracks';\n }\n } catch (e) { /* the studio's answer stands */ }\n }\n if (result && result.success && __lint && __lint.length && !result.lintWarnings) {\n result.lintWarnings = __lint;\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat(__lint.map(function (w) {\n return { code: String(w.code || 'lint'), message: String(w.message || w.code || 'scene lint warning'), ...(w.itemId ? { items: [w.itemId] } : {}) };\n }));\n if (result.fidelity.note) delete result.fidelity.note;\n }\n if (result && result.success && __fit) result.platformFit = __fit;\n" +
@@ -6346,7 +6519,8 @@ String((() => {
6346
6519
  warning: `${frames} frames at ${Math.round(fps * 1000) / 1000} fps is ${actual.toFixed(3)} s — ${((actual - videoDuration) * 1000).toFixed(1)} ms over the ${videoDuration} s asked for. For a hard cap (e.g. a 6 s bumper) use duration ${fit.toFixed(4)} (${Math.floor(videoDuration * fps)} frames).`,
6347
6520
  })};\n`;
6348
6521
  })()) +
6349
- "\n if (__stillT !== null) {\n try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }\n if (result && result.success) { result.time = __stillT; if (__stillTimeNote) result.stillTimeNote = __stillTimeNote; }\n }\n // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the\n // camera's frame map, so a HUD item is placed as if the camera had not moved.\n if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(" +
6522
+ "\n if (__pdfEvaluated && typeof app._restoreReactionJournal === 'function') {\n try { app._restoreReactionJournal(); } catch (_) { /* the page is already rendered */ }\n }\n if (__stillT !== null) {\n try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }\n if (__pausedForStill) { try { app.resumeKeyframeTimeline(); } catch (_) { /* the still is already rendered */ } }\n if (result && result.success) { result.time = __stillT; if (__stillTimeNote) result.stillTimeNote = __stillTimeNote; }\n }\n // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the\n // camera's frame map, so a HUD item is placed as if the camera had not" +
6523
+ " moved.\n if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(" +
6350
6524
  String(JSON.stringify(format)) +
6351
6525
  ") !== -1) {\n try {\n const __hud = app.screenSpaceItems() || [];\n if (__hud.length) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'screen_space_ignored', message: __hud.length + ' screen-space (HUD) item(s) are written at their frame coordinates in world space: " +
6352
6526
  String(format) +
@@ -7396,6 +7570,57 @@ String(jsLiteral(format)) +
7396
7570
 
7397
7571
 
7398
7572
 
7573
+
7574
+
7575
+
7576
+
7577
+
7578
+
7579
+
7580
+
7581
+
7582
+
7583
+
7584
+
7585
+
7586
+
7587
+
7588
+
7589
+
7590
+
7591
+
7592
+
7593
+
7594
+
7595
+
7596
+
7597
+
7598
+
7599
+
7600
+
7601
+
7602
+
7603
+
7604
+
7605
+
7606
+
7607
+
7608
+
7609
+
7610
+
7611
+
7612
+
7613
+
7614
+
7615
+
7616
+
7617
+
7618
+
7619
+
7620
+
7621
+
7622
+
7623
+
7399
7624
 
7400
7625
 
7401
7626
 
@@ -15162,6 +15387,66 @@ String(S(report)) +
15162
15387
  * reassembled into it. Naming them individually is the point: the kit's
15163
15388
  * options were discoverable only by reading FxTool's source.
15164
15389
  */
15390
+ /**
15391
+ * pinepaper_instances — a swarm of up to a million marks as one item. Every field passes through to the
15392
+ * engine, which validates it and names a bad one; the reply carries describeInstances, so a subsampled or
15393
+ * capped swarm says so (drawn vs count) in the call that made it.
15394
+ */
15395
+ generateInstances(input) {
15396
+ const S = (v) => JSON.stringify(v);
15397
+ const { action, itemId, name, ...rest } = input;
15398
+ const spec = {};
15399
+ for (const [k, v] of Object.entries(rest))
15400
+ if (v !== undefined)
15401
+ spec[k] = v;
15402
+ const refuse = (m) => `if (typeof app.${m} !== 'function') { return { success: false, error: 'this studio has no instances (app.${m}): update PinePaper Studio' }; }`;
15403
+ const why = `(app.lastReceipt && Array.isArray(app.lastReceipt.warnings) && app.lastReceipt.warnings.map(function (w) { return w && (w.message || w.reason); }).filter(Boolean).join(' ')) || ''`;
15404
+ switch (action) {
15405
+ case 'rules':
15406
+ return `
15407
+ // Instances: the rules, layouts and shapes this studio has
15408
+ (function() {
15409
+ // @engine-methods instanceRules
15410
+ ${refuse('instanceRules')}
15411
+ return Object.assign({ success: true }, app.instanceRules());
15412
+ })();`.trim();
15413
+ case 'describe':
15414
+ return `
15415
+ // Instances: describe ${oneLine(itemId ?? '')}
15416
+ (function() {
15417
+ // @engine-methods describeInstances
15418
+ ${refuse('describeInstances')}
15419
+ const d = app.describeInstances(${S(itemId)});
15420
+ if (!d || d.ok === false) { return { success: false, error: (d && (d.error || d.reason)) || 'no swarm has the id ' + ${S(itemId)} }; }
15421
+ return Object.assign({ success: true, itemId: ${S(itemId)} }, d);
15422
+ })();`.trim();
15423
+ case 'modify':
15424
+ return `
15425
+ // Instances: modify ${oneLine(itemId ?? '')}
15426
+ (function() {
15427
+ // @engine-methods setInstances describeInstances
15428
+ ${refuse('setInstances')}
15429
+ const r = app.setInstances(${S(itemId)}, ${S(spec)});
15430
+ if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || ${why} || 'the swarm was not changed' }; }
15431
+ const d = app.describeInstances(${S(itemId)}) || {};
15432
+ return { success: true, itemId: ${S(itemId)}, spec: r.spec, count: d.count, drawn: d.drawn, subsampled: d.subsampled, path: d.path, determinism: d.determinism };
15433
+ })();`.trim();
15434
+ case 'create':
15435
+ default:
15436
+ return `
15437
+ // Instances: create a swarm${spec.count ? ` of ${oneLine(String(spec.count))}` : ''}
15438
+ (function() {
15439
+ // @engine-methods describeInstances
15440
+ ${refuse('describeInstances')}
15441
+ const item = app.create('instances', ${S(spec)});
15442
+ if (!item) { return { success: false, error: ${why} || 'the studio made no swarm from that spec' }; }
15443
+ const itemId = item.data && (item.data.registryId || item.data.id);${name ? `
15444
+ try { item.name = ${S(name)}; } catch (_) {}` : ''}
15445
+ const d = app.describeInstances(itemId) || {};
15446
+ return { success: true, itemId: itemId, count: d.count, drawn: d.drawn, subsampled: d.subsampled, path: d.path, rule: d.rule, determinism: d.determinism };
15447
+ })();`.trim();
15448
+ }
15449
+ }
15165
15450
  generateStick(input) {
15166
15451
  const S = (v) => JSON.stringify(v);
15167
15452
  const { action, ...rest } = input;
@@ -15211,7 +15496,7 @@ String(S(report)) +
15211
15496
  if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool (the stick kit is vendored from mcp-cloud)' }; }
15212
15497
  const r = await app.${fn}(${S(opts)});
15213
15498
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the stick kit produced nothing' }; }
15214
- return { success: true, ...r };
15499
+ return { success: true, ...r${action === 'figure' ? ", anchor: app.stickFigureAnchorsFeet === true ? 'feet' : 'hip'" : ''} };
15215
15500
  })();`.trim();
15216
15501
  }
15217
15502
  /**
@@ -15566,7 +15851,8 @@ String(S(opts)) +
15566
15851
  ).trim();
15567
15852
  }
15568
15853
  case 'create': {
15569
- const v = (input.visual ?? {});
15854
+ // visual: false means no drawing options. The chip is editor-only, and no export draws it.
15855
+ const v = (input.visual || {});
15570
15856
  const { startTime, duration, ...visualOnly } = v;
15571
15857
  const placement = (startTime !== undefined || duration !== undefined)
15572
15858
  ? { ...(startTime !== undefined ? { startTime } : {}), ...(duration !== undefined ? { duration } : {}) }
@@ -16348,11 +16634,28 @@ function generateContraptionCode(input) {
16348
16634
  ...(input.elseTrigger ? { elseTrigger: input.elseTrigger } : {}),
16349
16635
  ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
16350
16636
  };
16351
- return `${refuse('addContraption')}
16352
- const contraptionId = app.addContraption(${jsLiteral(config)});
16353
- const missing = ${jsLiteral(targets)}.filter((id) => !app.getItemById(id));
16354
- return { success: true, action: 'add_contraption', contraptionId,
16355
- ...(missing.length ? { warning: 'no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does' } : {}), };`;
16637
+ return ("" +
16638
+ String(refuse('addContraption')) +
16639
+ "\n const contraptionId = app.addContraption(" +
16640
+ String(jsLiteral(config)) +
16641
+ ");\n const missing = " +
16642
+ String(jsLiteral(targets)) +
16643
+ ".filter((id) => !app.getItemById(id));\n // AN EVENT'S NAME IS NOT ITS CHANNEL (DBLEXP33). A timed event and a pulse by eventId both fire the id create\n // returned; a rule on the event's name ran only when that literal name was pulsed, so it stayed at fireCount 0\n // with no word said. Named here, with the id to use.\n const __src = " +
16644
+ String(jsLiteral(input.sourceEvent ?? '')) +
16645
+ ";\n const __named = (app.itemRegistry && app.itemRegistry.getAll ? app.itemRegistry.getAll() : [])\n .filter((e) => e && e.item && e.item.data && e.item.data.isEvent && e.item.data.eventName === __src && (e.id || e.itemId) !== __src)\n .map((e) => e.id || e.itemId);\n // A studio that resolves a name held by one event to its id (FxTool #346) stores the id: read the rule back\n // rather than trust a flag, so the warning is true on every build.\n const __stored = typeof app.listContraptions === 'function'\n ? (app.listContraptions() || []).find((c) => c && c.id === contraptionId) : null;\n const __resolved = !!(__stored && __named.indexOf(__stored.sourceEvent) >= 0);\n const " +
16646
+ "__warnings = [];\n if (missing.length) __warnings.push('no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does');\n if (__named.length > 1 && !__resolved) __warnings.push(__named.length + ' events are named ' + JSON.stringify(__src) + ' (' + __named.join(', ') + '), so the rule cannot tell which one: it runs only when the literal name is pulsed. Pass one eventId as sourceEvent.');\n else if (__named.length && !__resolved) __warnings.push('sourceEvent ' + JSON.stringify(__src) + ' is the NAME of event ' + __named[0] + ': its timer and a pulse of its id fire ' + __named[0] + ', not the name, so this rule runs only when the literal name ' + JSON.strin" +
16647
+ "gify(__src) + ' is pulsed. Use sourceEvent ' + JSON.stringify(__named[0]) + ' to run it when that event fires.');\n return { success: true, action: 'add_contraption', contraptionId,\n ...(__resolved ? { sourceEventResolvedTo: __stored.sourceEvent } : __named.length === 1 ? { eventIdForName: __named[0] } : {}),\n ...(__warnings.length ? { warning: __warnings.join(' ') } : {}), };"
16648
+
16649
+
16650
+
16651
+
16652
+
16653
+
16654
+
16655
+
16656
+
16657
+
16658
+ );
16356
16659
  }
16357
16660
  case 'remove_contraption':
16358
16661
  return `${refuse('removeContraption')}