@pinepaper.studio/mcp-server 1.6.33 → 1.6.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +102 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/character/perform.d.ts +14 -0
  4. package/dist/character/perform.d.ts.map +1 -1
  5. package/dist/character/perform.js +29 -3
  6. package/dist/character/perform.js.map +1 -1
  7. package/dist/index.js +3 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  10. package/dist/ontology/upstream-vocabulary.js +1 -1
  11. package/dist/tools/batch-macros.d.ts +20 -1
  12. package/dist/tools/batch-macros.d.ts.map +1 -1
  13. package/dist/tools/batch-macros.js +146 -7
  14. package/dist/tools/batch-macros.js.map +1 -1
  15. package/dist/tools/compact-descriptions.js +1 -1
  16. package/dist/tools/compact-descriptions.js.map +1 -1
  17. package/dist/tools/definitions.d.ts.map +1 -1
  18. package/dist/tools/definitions.js +87 -68
  19. package/dist/tools/definitions.js.map +1 -1
  20. package/dist/tools/engine-lists.d.ts +4 -4
  21. package/dist/tools/engine-lists.d.ts.map +1 -1
  22. package/dist/tools/engine-lists.js +13 -4
  23. package/dist/tools/engine-lists.js.map +1 -1
  24. package/dist/tools/engine-methods.d.ts.map +1 -1
  25. package/dist/tools/engine-methods.js +1 -0
  26. package/dist/tools/engine-methods.js.map +1 -1
  27. package/dist/tools/engine-surface.d.ts +1 -1
  28. package/dist/tools/engine-surface.d.ts.map +1 -1
  29. package/dist/tools/engine-surface.js +8 -6
  30. package/dist/tools/engine-surface.js.map +1 -1
  31. package/dist/tools/handlers/beats.d.ts +1 -1
  32. package/dist/tools/handlers.d.ts.map +1 -1
  33. package/dist/tools/handlers.js +24 -10
  34. package/dist/tools/handlers.js.map +1 -1
  35. package/dist/tools/lane.d.ts +8 -1
  36. package/dist/tools/lane.d.ts.map +1 -1
  37. package/dist/tools/lane.js +54 -13
  38. package/dist/tools/lane.js.map +1 -1
  39. package/dist/tools/minimal-descriptions.js +1 -1
  40. package/dist/tools/minimal-descriptions.js.map +1 -1
  41. package/dist/tools/shape-params.d.ts +2 -2
  42. package/dist/tools/shape-params.js +2 -2
  43. package/dist/tools/stick-vocabulary.d.ts +1 -1
  44. package/dist/tools/stick-vocabulary.js +1 -1
  45. package/dist/tools/toolkits.d.ts +1 -1
  46. package/dist/tools/toolkits.d.ts.map +1 -1
  47. package/dist/tools/toolkits.js +7 -2
  48. package/dist/tools/toolkits.js.map +1 -1
  49. package/dist/types/code-generator.d.ts +32 -0
  50. package/dist/types/code-generator.d.ts.map +1 -1
  51. package/dist/types/code-generator.js +298 -69
  52. package/dist/types/code-generator.js.map +1 -1
  53. package/dist/types/schemas.d.ts +74 -30
  54. package/dist/types/schemas.d.ts.map +1 -1
  55. package/dist/types/schemas.js +13 -8
  56. package/dist/types/schemas.js.map +1 -1
  57. package/manifest.json +1 -1
  58. package/package.json +1 -1
@@ -124,18 +124,29 @@ ${itemVarName}.${propertyName} = (function() {
124
124
  };
125
125
  })();`;
126
126
  }
127
- // Linear: a diagonal across a 100 px box at the requested position.
128
- const defaultOrigin = origin || [position.x - 50, position.y - 50];
129
- const defaultDestination = destination || [position.x + 50, position.y + 50];
127
+ // A LINEAR RAMP FITS THE ITEM, top to bottom, as the engine's own app.create does (FIREFLY31). This was a fixed
128
+ // 100 px diagonal at the requested position. Leaving the ends out is not enough here: this assigns straight to
129
+ // Paper, where a gradient with no ends is zero-length. An end the caller gave is kept as given.
130
+ const fx = position.x, fy = position.y;
131
+ const o = origin ? `[${origin[0]}, ${origin[1]}]`
132
+ : destination ? `[${destination[0]}, __gb ? __gb.y : ${fy - 50}]`
133
+ : `(__gb ? [__gb.x + __gb.width / 2, __gb.y] : [${fx}, ${fy - 50}])`;
134
+ const d = destination ? `[${destination[0]}, ${destination[1]}]`
135
+ : origin ? `[${origin[0]}, __gb ? __gb.y + __gb.height : ${fy + 50}]`
136
+ : `(__gb ? [__gb.x + __gb.width / 2, __gb.y + __gb.height] : [${fx}, ${fy + 50}])`;
130
137
  return `
131
- ${itemVarName}.${propertyName} = {
132
- gradient: {
133
- stops: [${stopsCode}],
134
- radial: false
135
- },
136
- origin: [${defaultOrigin[0]}, ${defaultOrigin[1]}],
137
- destination: [${defaultDestination[0]}, ${defaultDestination[1]}]
138
- };`;
138
+ ${itemVarName}.${propertyName} = (function() {
139
+ const __b = ${itemVarName} && ${itemVarName}.bounds;
140
+ const __gb = __b && isFinite(__b.x) && isFinite(__b.y) && __b.width > 0 && __b.height > 0 ? __b : null;
141
+ return {
142
+ gradient: {
143
+ stops: [${stopsCode}],
144
+ radial: false
145
+ },
146
+ origin: ${o},
147
+ destination: ${d}
148
+ };
149
+ })();`;
139
150
  }
140
151
  /**
141
152
  * Generate shadow code for an item
@@ -363,7 +374,10 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
363
374
  createScale.scaleY = num(properties.scaleY);
364
375
  const hasCreateScale = Object.keys(createScale).length > 0;
365
376
  const SCALE_KEYS = ['scale', 'scaleX', 'scaleY'];
366
- 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'
367
381
  ? (!CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k))
368
382
  || (k === 'letterSpacing' && letterSpacingCss(properties[k]) === undefined)
369
383
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
@@ -411,6 +425,10 @@ const item = app.create(${jsLiteral(itemType)}, ${JSON.stringify(params, null, 2
411
425
  if (opacity !== undefined) {
412
426
  code += `\nitem.opacity = ${opacity};`;
413
427
  }
428
+ if (createVisible !== undefined) {
429
+ code += `
430
+ if (item) item.visible = ${createVisible};`;
431
+ }
414
432
  if (hasCreateScale) {
415
433
  code += `
416
434
  const __scale = (function(it) {
@@ -1547,6 +1565,31 @@ export function animationOptionWarning(options) {
1547
1565
  return unreadKeysWarning('animationOptions', ANIMATION_OPTION_KEYS, options);
1548
1566
  }
1549
1567
  const VISIBILITY_REACTION = /^on_(event|enter|exit)_set_visibility$/;
1568
+ /** The relations that PULSE params.event (FxTool js/relations/triggers.js dispatchTrigger). */
1569
+ const EVENT_PULSING_RELATIONS = new Set(['on_complete', 'at_beat']);
1570
+ /**
1571
+ * ONCOMP34: AN EVENT'S NAME IS NOT ITS CHANNEL, here as in DBLEXP33. on_complete / at_beat pulse params.event
1572
+ * verbatim, and a reaction listens on its source's id, so {event: 'done'} (the name of event item_4) pulsed a
1573
+ * channel nothing listens on: bound, stored, no warning, never seen. A name exactly one event holds, and that no
1574
+ * item has as its id, is put back as that event's id before the bind; a name two events share is named instead.
1575
+ * Statements, run in the page; `paramsVar` is mutated in place and `__ev` holds the reply fields.
1576
+ */
1577
+ function eventNameResolveJs(relationType, paramsVar) {
1578
+ if (!EVENT_PULSING_RELATIONS.has(String(relationType)))
1579
+ return 'const __ev = {};';
1580
+ return `const __ev = {};
1581
+ (function(p) {
1582
+ if (!p || typeof p.event !== 'string' || !p.event || app.getItemById(p.event)) return;
1583
+ const ids = (app.itemRegistry && app.itemRegistry.getAll ? app.itemRegistry.getAll() : [])
1584
+ .filter(function(e) { return e && e.item && e.item.data && e.item.data.isEvent && e.item.data.eventName === p.event; })
1585
+ .map(function(e) { return e.id || e.itemId; });
1586
+ if (ids.length === 1) { __ev.eventResolvedFrom = p.event; __ev.eventResolvedTo = ids[0]; p.event = ids[0]; }
1587
+ 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.';
1588
+ })(${paramsVar});`;
1589
+ }
1590
+ /** Spread into a reply after its own fields: the resolve fields, with an event warning joined onto any warning. */
1591
+ const EV_MERGE_JS = (replyVar, key = 'warning') => `Object.keys(__ev).forEach(function(k) { if (k !== 'warning') ${replyVar}[k] = __ev[k]; });
1592
+ if (__ev.warning) ${replyVar}.${key} = ${replyVar}.${key} ? ${replyVar}.${key} + ' ' + __ev.warning : __ev.warning;`;
1550
1593
  /**
1551
1594
  * A visibility reaction reads params.visible and nothing else: absent means show. `value`, which the other set_*
1552
1595
  * reactions take, was ignored, so {value: false} SHOWED the item and a hide did nothing (issue #29 O4).
@@ -1576,14 +1619,18 @@ String(JSON.stringify(sourceId)) +
1576
1619
  String(JSON.stringify(targetId)) +
1577
1620
  "].filter(function(id) { return !app._isKnownRelationEndpoint(norm(id)); });\n if (bad.length) {\n return { success: false, relationType: " +
1578
1621
  String(jsLiteral(relationType)) +
1579
- ", 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(" +
1622
+ ", 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 = " +
1623
+ String(JSON.stringify(params, null, 2)) +
1624
+ ";\n " +
1625
+ String(eventNameResolveJs(relationType, '__params')) +
1626
+ "\n const success = app.addRelation(" +
1580
1627
  String(jsLiteral(sourceId)) +
1581
1628
  ", " +
1582
1629
  String(jsLiteral(targetId)) +
1583
1630
  ", " +
1584
1631
  String(jsLiteral(relationType)) +
1585
- ", " +
1586
- 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();
1632
+ ", __params);\n if (!success) {\n const __why = " +
1633
+ 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();
1587
1634
  }
1588
1635
  /**
1589
1636
  * The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
@@ -1721,6 +1768,11 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
1721
1768
  const f = o.fade;
1722
1769
  if (f && typeof f === 'object' && !Array.isArray(f))
1723
1770
  scalarRanges(`${where}.fade`, f, ['in', 'out']);
1771
+ // fade takes 'none', 'linear' (or true / false) or { in, out }. Any other word is not read, so the
1772
+ // particles keep the default linear fade over their life: fade 'out' looked like an option (EMITFADE34).
1773
+ if (typeof f === 'string' && f !== 'none' && f !== 'linear') {
1774
+ 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).`);
1775
+ }
1724
1776
  };
1725
1777
  scalarRanges('spec', out, [...SCALAR_ONLY, ...SPEC_ONLY]);
1726
1778
  bursts.forEach((b, i) => { if (b && typeof b === 'object')
@@ -2305,6 +2357,29 @@ function generateExecuteGeneratorCode(generatorName, params, region) {
2305
2357
  * at all and draws full-canvas). So the item is checked first and refused by
2306
2358
  * name, and the result says whether this studio clips to shapes.
2307
2359
  */
2360
+ /** Relations the studio declares ONCE over a set (multiInstance: false in js/relations/collection.js). */
2361
+ export const COLLECTION_RELATIONS = new Set(['phase_wave', 'ring_forming', 'flock']);
2362
+ /**
2363
+ * The part of an export region that is rendered: the region itself, or, when
2364
+ * outputWidth / outputHeight have another aspect, the largest box of that aspect
2365
+ * centred in it (a cover crop). Never larger than the region.
2366
+ */
2367
+ export function regionCoverCrop(r) {
2368
+ const outW = r.outputWidth ?? Math.round(r.width);
2369
+ const outH = r.outputHeight ?? Math.round(r.height);
2370
+ const want = outW / outH, have = r.width / r.height;
2371
+ // Under half a source pixel of difference on either side is the same box.
2372
+ if (!(outW > 0 && outH > 0) || Math.abs(r.width - r.height * want) < 0.5 && Math.abs(r.height - r.width / want) < 0.5) {
2373
+ return { x: r.x, y: r.y, width: r.width, height: r.height, cropped: false };
2374
+ }
2375
+ const round = (v) => Math.round(v * 1000) / 1000;
2376
+ if (want > have) {
2377
+ const h = r.width / want;
2378
+ return { x: r.x, y: round(r.y + (r.height - h) / 2), width: r.width, height: round(h), cropped: true };
2379
+ }
2380
+ const w = r.height * want;
2381
+ return { x: round(r.x + (r.width - w) / 2), y: r.y, width: round(w), height: r.height, cropped: true };
2382
+ }
2308
2383
  function emitGeneratorRun(generatorName, paramsJson, region) {
2309
2384
  const options = JSON.stringify(region ? { region } : {});
2310
2385
  const itemId = region && typeof region.shape === 'object' ? region.shape.itemId : null;
@@ -3602,7 +3677,7 @@ String(JSON.stringify(validated.presetId)) +
3602
3677
  if (validated.targetId)
3603
3678
  return code;
3604
3679
  return code
3605
- .replace(`return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: ${jsLiteral(validated.sourceId)},`, `return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: null,`)
3680
+ .replace(`const __reply = { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: ${jsLiteral(validated.sourceId)},`, `const __reply = { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: null,`)
3606
3681
  .replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
3607
3682
  }
3608
3683
  /**
@@ -4297,15 +4372,17 @@ String(jsLiteral(source)) +
4297
4372
  String(jsLiteral(target)) +
4298
4373
  "];\n if (sourceId && targetId) {\n const params = " +
4299
4374
  String(paramsStr) +
4300
- ";\n // A refused relation is not listed as made (owner rule 1).\n if (app.addRelation(sourceId, targetId, " +
4375
+ ";\n " +
4376
+ String(eventNameResolveJs(type, 'params')) +
4377
+ "\n // A refused relation is not listed as made (owner rule 1).\n if (app.addRelation(sourceId, targetId, " +
4301
4378
  String(jsLiteral(type)) +
4302
- ", params)) {\n results.relations.push({ source: " +
4379
+ ", params)) {\n results.relations.push(Object.assign({ source: " +
4303
4380
  String(jsLiteral(source)) +
4304
4381
  ", target: " +
4305
4382
  String(jsLiteral(target)) +
4306
4383
  ", type: " +
4307
4384
  String(jsLiteral(type)) +
4308
- " });\n const __o = { _: 0" +
4385
+ " }, __ev.eventResolvedTo ? { eventResolvedFrom: __ev.eventResolvedFrom, eventResolvedTo: __ev.eventResolvedTo } : {}));\n if (__ev.warning) (results.optionWarnings = results.optionWarnings || []).push(__ev.warning);\n const __o = { _: 0" +
4309
4386
  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"));
4310
4387
  }
4311
4388
  // Apply animations
@@ -5308,6 +5385,45 @@ const __members = app.itemRegistry.getAll()
5308
5385
  if (!__members.length) throw new Error('no item is named like ' + ${jsLiteral(op.sourceId)} + ': name items with properties.name');`
5309
5386
  : `const __members = [{ id: ${srcRef} }];
5310
5387
  if (!app.getItemById(__members[0].id)) throw new Error('Source not found: ' + __members[0].id);`;
5388
+ // A COLLECTION RULE IS DECLARED ONCE OVER THE SET (PHASEWAVE32, RINGFORM33). phase_wave, ring_forming
5389
+ // and flock take their members from params.members (or a group's children) and space them by rank in
5390
+ // that list; one relation per item gave each a set of one, so every disc took slot 0 (all at phase 0,
5391
+ // all on the angle-0 point of the ring). Bound once, on the first match, with the matches as members.
5392
+ const rel = String(op.relationType);
5393
+ const relParams = (op.relationOptions ?? {});
5394
+ if (glob && op.update !== true && COLLECTION_RELATIONS.has(rel)) {
5395
+ // ring_forming centres on the source's own bounds by default, and the source is a member that
5396
+ // moves: the centre is fixed at the members' centre as bound, unless a target or center was given.
5397
+ const ringCentre = rel === 'ring_forming' && noTarget && relParams.center === undefined;
5398
+ return ("\n" +
5399
+ String(members) +
5400
+ "\nconst targetId = " +
5401
+ String(noTarget ? 'null' : tgtRef) +
5402
+ ";\nconst target = " +
5403
+ String(noTarget ? 'null' : 'app.getItemById(targetId)') +
5404
+ ";\n" +
5405
+ String(noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n") +
5406
+ "const __ids = __members.map((m) => m.id);\nconst __p = Object.assign({}, " +
5407
+ String(relOpts) +
5408
+ String(relParams.members === undefined ? ', { members: { ids: __ids } }' : '') +
5409
+ ");\n" +
5410
+ String(ringCentre ? `{
5411
+ let __b = null;
5412
+ __ids.forEach((id) => { const it = app.getItemById(id); if (it && it.bounds) __b = __b ? __b.unite(it.bounds) : it.bounds.clone(); });
5413
+ if (__b) __p.center = { x: __b.center.x, y: __b.center.y };
5414
+ }
5415
+ ` : '') +
5416
+ "const __src = app.getItemById(__ids[0]);\nconst __existing = app.relationRegistry && app.relationRegistry.getAssociations\n && app.relationRegistry.getAssociations(__ids[0], " +
5417
+ String(type) +
5418
+ ").some((a) => a.target === targetId || (targetId === null && a.target == null));\nconst ok = (__existing && typeof app.modifyRelationParams === 'function')\n ? (app.modifyRelationParams(__ids[0], targetId, " +
5419
+ String(type) +
5420
+ ", __p) || (targetId === null && app.modifyRelationParams(__ids[0], undefined, " +
5421
+ String(type) +
5422
+ ", __p)))\n : app.addRelation(__src, target, " +
5423
+ String(type) +
5424
+ ", __p);\nif (!ok) {\n const __why = " +
5425
+ 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");
5426
+ }
5311
5427
  return ("\n" +
5312
5428
  String(stepGuard) +
5313
5429
  String(members) +
@@ -5349,14 +5465,18 @@ String(noTarget ? 'null' : tgtRef) +
5349
5465
  String(noTarget ? 'null' : 'app.getItemById(targetId)') +
5350
5466
  ";\nif (!source) throw new Error('Source not found: ' + sourceId);\n" +
5351
5467
  String(noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n") +
5352
- "const relationId = app.addRelation(source, target, " +
5353
- String(JSON.stringify(String(op.relationType))) +
5354
- ", " +
5468
+ "const __relOpts = " +
5355
5469
  String(relOpts) +
5356
- ");\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 = " +
5470
+ ";\n" +
5471
+ String(eventNameResolveJs(op.relationType, '__relOpts')) +
5472
+ "\nconst relationId = app.addRelation(source, target, " +
5473
+ String(JSON.stringify(String(op.relationType))) +
5474
+ ", __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 = " +
5357
5475
  String(relationRefusalReasonJs(jsLiteral(op.relationType))) +
5358
5476
  ";\n return { success: false, relationId: false, sourceId, targetId, relationType: " +
5359
- 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");
5477
+ String(JSON.stringify(op.relationType)) +
5478
+ ",\n error: 'the studio refused the ' + " +
5479
+ 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");
5360
5480
  case 'delete':
5361
5481
  const delRef = batchRef(op.itemId);
5362
5482
  return `
@@ -5586,7 +5706,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5586
5706
  }
5587
5707
  generateAgentExport(input) {
5588
5708
  const validated = AgentExportInputSchema.parse(input);
5589
- const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, audioCodec, transparent, alphaQuantizer, deterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
5709
+ 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;
5710
+ const regionSrc = region ? regionCoverCrop(region) : undefined;
5711
+ // Every video is deterministic unless the caller opts out (owner, 2026-11-07), so every receipt says whether its file repeats.
5712
+ const deterministic = askedDeterministic ?? (format === 'mp4' || format === 'webm');
5590
5713
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
5591
5714
  const videoEncodeOpts = {};
5592
5715
  // A RANGE of the timeline (FxTool #250): frames for t in [startTime, startTime + duration), each exactly the frame
@@ -5911,34 +6034,42 @@ String(stillTime === undefined ? ` if (['png', 'jpg', 'webp', 'svg', 'pdf'].ind
5911
6034
  ` : '') +
5912
6035
  " // 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 " +
5913
6036
  " }\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," +
5914
- " 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 " +
5915
- "= 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 " +
5916
- 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 = " +
6037
+ " 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" +
6038
+ "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" +
6039
+ "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 " +
6040
+ 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 = " +
5917
6041
  String(region.outputWidth ?? Math.round(region.width)) +
5918
6042
  ", rh = " +
5919
6043
  String(region.outputHeight ?? Math.round(region.height)) +
5920
6044
  ";\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" +
5921
6045
  "ckTime : 0));\n const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(" +
5922
- String(region.x) +
6046
+ String(regionSrc.x) +
5923
6047
  ", " +
5924
- String(region.y) +
6048
+ String(regionSrc.y) +
5925
6049
  ", " +
5926
- String(region.width) +
6050
+ String(regionSrc.width) +
5927
6051
  ", " +
5928
- String(region.height) +
6052
+ String(regionSrc.height) +
5929
6053
  "), rw, rh" +
5930
6054
  String(region.excludeForeign === false ? '' : ', { excludeForeign: true }') +
5931
6055
  ");\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 = " +
5932
- String(region.x) +
6056
+ String(regionSrc.x) +
5933
6057
  ", __ry = " +
5934
- String(region.y) +
6058
+ String(regionSrc.y) +
5935
6059
  ", __rr = " +
5936
- String(region.x + region.width) +
6060
+ String(regionSrc.x + regionSrc.width) +
5937
6061
  ", __rb = " +
5938
- String(region.y + region.height) +
6062
+ String(regionSrc.y + regionSrc.height) +
5939
6063
  ";\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" +
5940
6064
  " 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: " +
5941
6065
  String(JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height })) +
6066
+ String(regionSrc.cropped ? `,
6067
+ // A DIFFERENT OUTPUT ASPECT IS A CROP (REGION34). The studio grew the box to
6068
+ // the output's aspect instead, drawing content from outside the region and
6069
+ // black past the canvas edge. The server now trims the region about its
6070
+ // centre to the output's aspect, so the studio is only asked to scale.
6071
+ croppedTo: ${JSON.stringify({ x: regionSrc.x, y: regionSrc.y, width: regionSrc.width, height: regionSrc.height })},
6072
+ 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.')}` : '') +
5942
6073
  " };\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 " +
5943
6074
  " 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;"
5944
6075
 
@@ -5962,6 +6093,11 @@ String(JSON.stringify({ x: region.x, y: region.y, width: region.width, height: r
5962
6093
 
5963
6094
 
5964
6095
 
6096
+
6097
+
6098
+
6099
+
6100
+
5965
6101
 
5966
6102
 
5967
6103
 
@@ -6000,9 +6136,9 @@ String(encodeOptsJs ? `
6000
6136
  // Broadcast / bitrate. A store that rebuilds its options without
6001
6137
  // them is caught by the video report below, not assumed.
6002
6138
  ${encodeOptsJs.slice(2)},` : '') +
6003
- "\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" +
6004
- "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 " +
6005
- "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 = " +
6139
+ "\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" +
6140
+ "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 " +
6141
+ " 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 = " +
6006
6142
  String(INLINE_MAX_BYTES) +
6007
6143
  ";\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 " +
6008
6144
  "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 " +
@@ -6021,21 +6157,42 @@ String(gifLoop !== undefined ? `// A LOOP COUNT (8.30): _quickExportVideo rebuil
6021
6157
  "\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 " +
6022
6158
  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 > " +
6023
6159
  String(maxBytes) +
6024
- ") {\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 > " +
6160
+ ") {\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" +
6161
+ "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 > " +
6025
6162
  String(maxBytes) +
6026
6163
  "; k++) {\n const f = Math.sqrt(" +
6027
6164
  String(maxBytes) +
6028
- " / 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 }" +
6165
+ " / 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:" +
6166
+ " w, height: h }" +
6029
6167
  String(gifLoop !== undefined ? `, { loop: ${JSON.stringify(gifLoop)} }` : '') +
6030
- "));\n if (!again) break;\n result = await deliver(again);\n attempts.push({ width: w, height: h, bytes: result.size });\n }\n result.budget = { maxBytes: " +
6168
+ "));\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: " +
6031
6169
  String(maxBytes) +
6032
6170
  ", met: !!(result.success && result.size <= " +
6033
6171
  String(maxBytes) +
6034
- "), attempts: attempts };\n if (result.success && result.size > " +
6172
+ "), 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 > " +
6035
6173
  String(maxBytes) +
6036
- ") {\n result.warning = 'still ' + result.size + ' bytes after shrinking to ' + w + 'x' + h + ' — over the ' + " +
6174
+ ") {\n result.warning = 'still ' + result.size + ' bytes at ' + bestDims.width + 'x' + bestDims.height + ', the smallest of ' + attempts.length + ' tries — over the ' + " +
6037
6175
  String(maxBytes) +
6038
- " + '-byte budget. Lower fps, shorten duration, or use quality \"draft\".';\n } else if (result.success) {\n result.dimensions = { width: w, height: h };\n }\n }"
6176
+ " + '-byte budget. Lower fps, shorten duration, or use quality \"draft\".';\n }\n }"
6177
+
6178
+
6179
+
6180
+
6181
+
6182
+
6183
+
6184
+
6185
+
6186
+
6187
+
6188
+
6189
+
6190
+
6191
+
6192
+
6193
+
6194
+
6195
+
6039
6196
 
6040
6197
 
6041
6198
 
@@ -6162,7 +6319,8 @@ String(pdfOpts.searchableText !== false) +
6162
6319
 
6163
6320
 
6164
6321
  ) : '') +
6165
- "\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: " +
6322
+ "\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" +
6323
+ "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: " +
6166
6324
  String(pdfOpts?.dpi ?? 'settings.dpi') +
6167
6325
  ",\n download: false," +
6168
6326
  String(pdfOpts?.searchableText === false ? '\n searchableText: false,' : '') +
@@ -6171,18 +6329,20 @@ String(pdfOpts?.orientation ? `\n orientation: ${JSON.stringify(pdfOp
6171
6329
  String(pdfOpts?.bleed !== undefined ? `\n includeBleed: ${pdfOpts.bleed > 0}, bleed: ${pdfOpts.bleed},` : '') +
6172
6330
  String(pdfOpts?.trimMarks !== undefined ? `\n trimMarks: ${pdfOpts.trimMarks},` : '') +
6173
6331
  "\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 " +
6174
- " // 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 } : {})," +
6175
- String(pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && ((pdfOpts.bleed ?? 0) > 0 || pdfOpts.trimMarks) ? `
6176
- // Bleed and trim marks on a NAMED paper size: the studio keeps the
6177
- // page at that size and centres the art, so neither appears (prod
6178
- // retest: MediaBox = TrimBox = A4, no marks). Said, not silent.
6179
- 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'.`)}],` : '') +
6180
- "\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" +
6181
- "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" +
6182
- "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() " +
6183
- "{ 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" +
6184
- "' && 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" +
6185
- "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 * " +
6332
+ " // 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 } : {})," +
6333
+ String(pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && pdfOpts.trimMarks ? `
6334
+ // TRIM MARKS ON A NAMED SHEET. Bleed is applied there (page = sheet + 2 x bleed, TrimBox = sheet), but
6335
+ // a studio before FxTool #347 draws no marks on a named sheet. Said unless the file's own record
6336
+ // says it drew them.
6337
+ ...((pdfOut && pdfOut.print && pdfOut.print.trimMarksDrawn) || (__pee.lastPdfReport && __pee.lastPdfReport.trimMarksDrawn) ? {} : {
6338
+ 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).`)}],
6339
+ }),` : '') +
6340
+ "\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" +
6341
+ "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() {" +
6342
+ "\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, " +
6343
+ "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 &" +
6344
+ "& 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" +
6345
+ " 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 * " +
6186
6346
  String(videoDuration) +
6187
6347
  " * 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" +
6188
6348
  "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" +
@@ -6248,10 +6408,10 @@ String(videoDuration) +
6248
6408
  ".264 needs even dimensions, so\n // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The\n // result's dimensions become what the file IS, and fidelity says why.\n const __vd = __vr && __vr.dimensions;\n if (result && result.success && __vd && Array.isArray(__vd.requested) && Array.isArray(__vd.output)\n && (__vd.requested[0] !== __vd.output[0] || __vd.requested[1] !== __vd.output[1])) {\n result.dimensions = { width: __vd.output[0], height: __vd.output[1] };\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'dimensions_rounded',\n message: 'the file is ' + __vd.outpu" +
6249
6409
  "t[0] + 'x' + __vd.output[1] + ', not the requested ' + __vd.requested[0] + 'x' + __vd.requested[1] + ': H.264 needs even dimensions, so an odd side was rounded up by 1 px (the extra row / column is background). For an exact-pixel spec, use an even canvas size, or crop the extra line when re-encoding (ffmpeg -vf crop=' + __vd.requested[0] + ':' + __vd.requested[1] + ':0:0).' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n" +
6250
6410
  String(deterministic ? (" // DETERMINISTIC: the container timestamps are pinned either way; the PIXELS\n // are only identical run to run on the software encoder, because the\n // browser may pick a different hardware backend each time (FxTool\n // e34ae03d: 92/255 between backends, 2/255 run to run on hardware). The\n // engine requests software and reports what it got.\n // Where the studio says outright whether the file repeats (bitExact, FxTool\n // PR #31: it test-encodes and keeps whichever encoder does — often the\n // HARDWARE one on a Mac, since Chrome's software H.264 is not repeatable at\n // some sizes), that answer decides; the encoder name is only the fallback\n // for studios that do not r" +
6251
- "eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n ...(__vr && __vr.cause ? { cause: __vr.cause } : {}),\n message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked' + (__vr && __vr.cause === 'h264-threaded' ? ': the software H.264 encoder is multi-threaded and " +
6252
- "takes no fixed quantizer' : '') + '). What still holds: the frames fed to the encoder are identical (compare frameDigest, not sha256), and the container is identical apart from the encoded samples. Do not cache this file by hash; export WebM for a byte-identical file.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.str" +
6253
- "ingify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'\n : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between runs if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n }\n"
6254
-
6411
+ "eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n ...(__vr && __vr.determinism && __vr.determinism.cause ? { cause: __vr.determinism.cause } : {}),\n message: 'this file will not repeat byte for byte: no encoder on this machine repeats at this size (the studio test-encoded and checked' + (__vr && __vr.determinism && __vr.determinism.cause === " +
6412
+ "'h264-threaded' ? ': the software H.264 encoder varies run to run at this size on this many CPUs' : '') + '). What still holds: the frames fed to the encoder are identical (compare frameDigest, not sha256), and the container is identical apart from the encoded samples. Do not cache this file by hash. For a byte-identical file, export WebM, or export_png_sequence (pinepaper_interchange) and encode the frames with your own FFmpeg, which the server does not provide.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = " +
6413
+ "(result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'this file may not repeat byte for byte: the encoder ran with hardwareAcceleration ' + JSON.stringify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'\n : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between runs if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);\n if (result.fidelity.note) delete result.fi" +
6414
+ "delity.note;\n }\n }\n"
6255
6415
 
6256
6416
 
6257
6417
 
@@ -6333,7 +6493,8 @@ String((() => {
6333
6493
  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).`,
6334
6494
  })};\n`;
6335
6495
  })()) +
6336
- "\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(" +
6496
+ "\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" +
6497
+ " moved.\n if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(" +
6337
6498
  String(JSON.stringify(format)) +
6338
6499
  ") !== -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: " +
6339
6500
  String(format) +
@@ -7383,6 +7544,57 @@ String(jsLiteral(format)) +
7383
7544
 
7384
7545
 
7385
7546
 
7547
+
7548
+
7549
+
7550
+
7551
+
7552
+
7553
+
7554
+
7555
+
7556
+
7557
+
7558
+
7559
+
7560
+
7561
+
7562
+
7563
+
7564
+
7565
+
7566
+
7567
+
7568
+
7569
+
7570
+
7571
+
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
+
7386
7598
 
7387
7599
 
7388
7600
 
@@ -9495,7 +9707,7 @@ String(name ? `\n name: ${JSON.stringify(name)},` : '') +
9495
9707
  "\n bounds: { x: b.x, y: b.y, width: b.width, height: b.height }" +
9496
9708
  String(mask ? `,\n mask: ${jsLiteral(mask)}` : '') +
9497
9709
  String(hasShadow ? ',\n shadow: __shadow' : '') +
9498
- String(loop !== undefined ? ",\n playback: (raster && raster.data && raster.data.animatedImage && raster.data.animatedImage.loop !== undefined) ? { applied: true, plays: raster.data.animatedImage.loop } : { applied: false, warning: 'loop was not applied: this image does not animate in this studio. Animated GIF and WebP do.' }" : '') +
9710
+ String(loop !== undefined ? ",\n playback: (raster && raster.data && raster.data.animatedImage && raster.data.animatedImage.loop !== undefined) ? { applied: true, plays: raster.data.animatedImage.loop } : { applied: false, warning: 'loop was not applied: this image does not animate in this studio. Animated GIF, WebP and APNG do.' }" : '') +
9499
9711
  String(hasLook ? ',\n look: __look' : '') +
9500
9712
  ",\n // Stored smaller than its source (the studio's cap): said, with the\n // way to keep every pixel. The studio reports native size where it can.\n ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)\n ? { stored: { width: entry.width, height: entry.height }, native: { width: entry.nativeWidth, height: entry.nativeHeight },\n note: 'the studio stored this image at ' + entry.width + 'x' + entry.height + ' (its source is ' + entry.nativeWidth + 'x' + entry.nativeHeight + '). Pass maxEdge: \"native\" to keep every pixel.' }\n : {})\n };\n } catch (e) {\n return { error: 'Failed to import image: ' + e.message };\n " +
9501
9713
  " }\n})();\n"
@@ -16335,11 +16547,28 @@ function generateContraptionCode(input) {
16335
16547
  ...(input.elseTrigger ? { elseTrigger: input.elseTrigger } : {}),
16336
16548
  ...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
16337
16549
  };
16338
- return `${refuse('addContraption')}
16339
- const contraptionId = app.addContraption(${jsLiteral(config)});
16340
- const missing = ${jsLiteral(targets)}.filter((id) => !app.getItemById(id));
16341
- return { success: true, action: 'add_contraption', contraptionId,
16342
- ...(missing.length ? { warning: 'no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does' } : {}), };`;
16550
+ return ("" +
16551
+ String(refuse('addContraption')) +
16552
+ "\n const contraptionId = app.addContraption(" +
16553
+ String(jsLiteral(config)) +
16554
+ ");\n const missing = " +
16555
+ String(jsLiteral(targets)) +
16556
+ ".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 = " +
16557
+ String(jsLiteral(input.sourceEvent ?? '')) +
16558
+ ";\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 " +
16559
+ "__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" +
16560
+ "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(' ') } : {}), };"
16561
+
16562
+
16563
+
16564
+
16565
+
16566
+
16567
+
16568
+
16569
+
16570
+
16571
+ );
16343
16572
  }
16344
16573
  case 'remove_contraption':
16345
16574
  return `${refuse('removeContraption')}