@pinepaper.studio/mcp-server 1.6.13 → 1.6.15

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 (94) hide show
  1. package/README.de.md +6 -1
  2. package/README.es.md +6 -1
  3. package/README.fr.md +6 -1
  4. package/README.hi.md +6 -1
  5. package/README.ja.md +6 -1
  6. package/README.ko.md +6 -1
  7. package/README.md +245 -6
  8. package/README.pt-BR.md +6 -1
  9. package/README.zh-CN.md +6 -1
  10. package/assets/brand/pinepaper-lockup-horizontal-white.svg +1 -0
  11. package/assets/brand/pinepaper-lockup-horizontal.svg +1 -0
  12. package/assets/edit-whenever.svg +1 -1
  13. package/dist/browser/puppeteer-controller.js +2 -2
  14. package/dist/browser/puppeteer-controller.js.map +1 -1
  15. package/dist/build-info.json +5 -0
  16. package/dist/design/design-systems.js +1 -1
  17. package/dist/design/design-systems.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/ontology/upstream-vocabulary.d.ts +23 -0
  22. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -0
  23. package/dist/ontology/upstream-vocabulary.js +1172 -0
  24. package/dist/ontology/upstream-vocabulary.js.map +1 -0
  25. package/dist/ontology/vocabulary.d.ts.map +1 -1
  26. package/dist/ontology/vocabulary.js +19 -0
  27. package/dist/ontology/vocabulary.js.map +1 -1
  28. package/dist/tools/definitions.d.ts.map +1 -1
  29. package/dist/tools/definitions.js +264 -70
  30. package/dist/tools/definitions.js.map +1 -1
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +24 -2
  33. package/dist/tools/engine-methods.js.map +1 -1
  34. package/dist/tools/engine-surface.d.ts +1 -1
  35. package/dist/tools/engine-surface.d.ts.map +1 -1
  36. package/dist/tools/engine-surface.js +37 -10
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/camera.d.ts.map +1 -1
  39. package/dist/tools/handlers/camera.js +17 -2
  40. package/dist/tools/handlers/camera.js.map +1 -1
  41. package/dist/tools/handlers/font.d.ts.map +1 -1
  42. package/dist/tools/handlers/font.js +48 -3
  43. package/dist/tools/handlers/font.js.map +1 -1
  44. package/dist/tools/handlers/maps.d.ts.map +1 -1
  45. package/dist/tools/handlers/maps.js +16 -0
  46. package/dist/tools/handlers/maps.js.map +1 -1
  47. package/dist/tools/handlers.d.ts +11 -1
  48. package/dist/tools/handlers.d.ts.map +1 -1
  49. package/dist/tools/handlers.js +426 -14
  50. package/dist/tools/handlers.js.map +1 -1
  51. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  52. package/dist/tools/minimal-descriptions.js +2 -0
  53. package/dist/tools/minimal-descriptions.js.map +1 -1
  54. package/dist/tools/shape-params.d.ts +13 -2
  55. package/dist/tools/shape-params.d.ts.map +1 -1
  56. package/dist/tools/shape-params.js +98 -3
  57. package/dist/tools/shape-params.js.map +1 -1
  58. package/dist/tools/stick-vocabulary.d.ts +1 -1
  59. package/dist/tools/stick-vocabulary.js +1 -1
  60. package/dist/tools/toolkits.d.ts.map +1 -1
  61. package/dist/tools/toolkits.js +2 -0
  62. package/dist/tools/toolkits.js.map +1 -1
  63. package/dist/types/code-generator.d.ts +27 -1
  64. package/dist/types/code-generator.d.ts.map +1 -1
  65. package/dist/types/code-generator.js +2506 -273
  66. package/dist/types/code-generator.js.map +1 -1
  67. package/dist/types/schemas.d.ts +1483 -239
  68. package/dist/types/schemas.d.ts.map +1 -1
  69. package/dist/types/schemas.js +259 -26
  70. package/dist/types/schemas.js.map +1 -1
  71. package/dist/utils/a11y.d.ts +67 -0
  72. package/dist/utils/a11y.d.ts.map +1 -0
  73. package/dist/utils/a11y.js +117 -0
  74. package/dist/utils/a11y.js.map +1 -0
  75. package/dist/utils/ad-package.d.ts +44 -0
  76. package/dist/utils/ad-package.d.ts.map +1 -0
  77. package/dist/utils/ad-package.js +121 -0
  78. package/dist/utils/ad-package.js.map +1 -0
  79. package/dist/utils/scc.d.ts +41 -0
  80. package/dist/utils/scc.d.ts.map +1 -0
  81. package/dist/utils/scc.js +185 -0
  82. package/dist/utils/scc.js.map +1 -0
  83. package/dist/utils/zip.d.ts +7 -0
  84. package/dist/utils/zip.d.ts.map +1 -0
  85. package/dist/utils/zip.js +82 -0
  86. package/dist/utils/zip.js.map +1 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.d.ts.map +1 -1
  89. package/dist/version.js +31 -1
  90. package/dist/version.js.map +1 -1
  91. package/icon.png +0 -0
  92. package/icon.svg +1 -0
  93. package/manifest.json +11 -2
  94. package/package.json +8 -4
@@ -5,6 +5,7 @@
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
7
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
8
+ import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
8
9
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
9
10
  import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
10
11
  // Diagram types
@@ -33,6 +34,38 @@ import { OntologyCompiler } from '../ontology/ontology-compiler.js';
33
34
  function isGradient(color) {
34
35
  return typeof color === 'object' && color !== null && 'type' in color && 'stops' in color;
35
36
  }
37
+ /**
38
+ * A colour OBJECT is a gradient spec, checked before it reaches the engine.
39
+ *
40
+ * {gradient: true, stops: ['#a', '#b']} — no type, bare-string stops, invalid
41
+ * hex — went to the engine as a plain colour and drew NOTHING, with success
42
+ * (round 9 ads, 1.82). The shape the tools document is {type: 'linear' |
43
+ * 'radial', stops: [{color, offset}], origin?, destination?}. Near-misses are
44
+ * normalised (a missing type is linear; bare stops get even offsets); what
45
+ * cannot be read is refused by name, with the shape.
46
+ */
47
+ function normalizeGradientSpec(v, key) {
48
+ const o = v;
49
+ const rawStops = Array.isArray(o.stops) ? o.stops : null;
50
+ const shape = `${key} as a gradient is {type: 'linear' | 'radial', stops: [{color: '#rrggbb', offset: 0..1}, …], origin?: [x, y], destination?: [x, y]}.`;
51
+ if (!rawStops || rawStops.length < 2)
52
+ return { error: `${key} is an object but not a gradient with at least 2 stops, so nothing would be drawn. ${shape}` };
53
+ const hexOk = (c) => !c.startsWith('#') || /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c);
54
+ const n = rawStops.length;
55
+ const stops = [];
56
+ for (let i = 0; i < n; i++) {
57
+ const st = rawStops[i];
58
+ const color = typeof st === 'string' ? st : (st && typeof st.color === 'string' ? st.color : null);
59
+ const offset = typeof st === 'string' ? i / (n - 1) : Number(st.offset ?? i / (n - 1));
60
+ if (!color || !hexOk(color.trim()))
61
+ return { error: `${key} stop ${i} has colour ${JSON.stringify(color)}, which is not a colour (a hex needs 3, 4, 6 or 8 digits). ${shape}` };
62
+ if (!(offset >= 0 && offset <= 1))
63
+ return { error: `${key} stop ${i} has offset ${JSON.stringify(st.offset)}; offsets run 0 to 1. ${shape}` };
64
+ stops.push({ color: color.trim(), offset });
65
+ }
66
+ const type = o.type === 'radial' ? 'radial' : 'linear';
67
+ return { gradient: { ...o, type, stops } };
68
+ }
36
69
  /**
37
70
  * Generate Paper.js gradient code for a fill color
38
71
  */
@@ -80,19 +113,79 @@ function generateBlendModeCode(itemVarName, blendMode) {
80
113
  /**
81
114
  * Template for creating items with support for gradients, shadows, and blend modes
82
115
  */
83
- function generateCreateItemCode(itemType, position, properties) {
116
+ function generateCreateItemCode(itemType, position, properties, dataFlags, positionGiven = true) {
117
+ // Colour objects are gradient specs: normalised, or refused by name.
118
+ for (const key of ['color', 'fillColor', 'strokeColor']) {
119
+ const v = properties[key];
120
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
121
+ const g = normalizeGradientSpec(v, key);
122
+ if ('error' in g) {
123
+ return `
124
+ // Create ${itemType} — refused: an unreadable gradient would draw nothing
125
+ ({ success: false, error: ${JSON.stringify(g.error)} });`.trim();
126
+ }
127
+ properties = { ...properties, [key]: g.gradient };
128
+ }
129
+ }
84
130
  // Extract special properties that need separate handling
85
131
  const { color, fillColor, strokeColor, shadowColor, shadowBlur, shadowOffset, blendMode, opacity, contentType, contentFormat, countdownTarget, countdownEndText, ...baseProperties } = properties;
132
+ // A PATH KEEPS ITS OWN COORDINATES WHEN NO POSITION WAS ASKED FOR (round 9
133
+ // HH, 1.81). The schema defaults position to (400, 300), and the engine
134
+ // places pathData AT x / y (so origin-centred data can be positioned) — so a
135
+ // pathData path authored in canvas coordinates was moved to the default,
136
+ // off by (+99.5, -170). The segments form was untouched only by accident of
137
+ // the engine's branch. With no position from the caller, a coordinate-built
138
+ // item (path from pathData / segments, line, arc) gets no x / y at all.
139
+ const omitXY = !positionGiven && isCoordinateBuilt(itemType, properties);
140
+ // `fit` is applied through app.fitText after creation (below), not sent to
141
+ // create(), so a studio without it cannot half-read it.
142
+ const textFit = itemType === 'text' && baseProperties.fit && typeof baseProperties.fit === 'object' ? baseProperties.fit : null;
143
+ const { fit: _fitDropped, ...createProps } = baseProperties;
144
+ void _fitDropped;
145
+ let textDirection = itemType === 'text' ? takeDirection(createProps) : null;
146
+ // RIGHT-TO-LEFT TEXT WITH NO DIRECTION GIVEN. The engine's default is auto,
147
+ // but it records nothing, so nothing could confirm it. For content with any
148
+ // RTL script, auto is sent explicitly — recorded, so checkable — and the
149
+ // result says which way it resolved.
150
+ if (itemType === 'text' && !textDirection && typeof createProps.content === 'string' && RTL_SCRIPT.test(createProps.content)) {
151
+ createProps.direction = 'auto';
152
+ textDirection = { value: 'auto' };
153
+ }
154
+ if (textDirection && 'value' in textDirection && textDirection.value === 'auto' && typeof createProps.content === 'string') {
155
+ textDirection.resolved = baseDirection(createProps.content);
156
+ }
157
+ const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
158
+ const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
86
159
  const params = {
87
- x: position.x,
88
- y: position.y,
89
- ...withRadiusAxes(baseProperties),
160
+ ...(omitXY ? {} : { x: position.x, y: position.y }),
161
+ ...withRadiusAxes(createProps),
90
162
  };
163
+ // "NO FILL" HAS THREE SPELLINGS, AND NONE OF THEM WORKED (round 7 AA, 1.50).
164
+ // fillColor:null was dropped by a truthiness check, so the engine's default
165
+ // blue applied; 'transparent' reached Paper, which read it as black. A
166
+ // stroke-only rectangle — whiteboard line art — could not be made at create.
167
+ // All three now mean no fill, applied after the item exists.
168
+ const noFill = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
169
+ const clearFill = (fillColor !== undefined && noFill(fillColor)) || (fillColor === undefined && color !== undefined && noFill(color));
170
+ // `color` ON A CLOSED PATH IS ITS FILL.
171
+ //
172
+ // The engine's path branch paints `color` as the STROKE whenever no fillColor
173
+ // is given — right for an open line, and the opposite of every shape, where
174
+ // color is the fill. So a closed blob or hand-drawn badge passed {color} came
175
+ // out as a hollow outline (round 6 U, 1.41). A closed path (closed:true, or
176
+ // pathData ending in Z) now takes color as its fill; an open one keeps the
177
+ // stroke it always had, so existing line drawings are unchanged.
178
+ const pathData = typeof baseProperties.pathData === 'string' ? baseProperties.pathData.trim() : '';
179
+ const closedPath = itemType === 'path'
180
+ && (baseProperties.closed === true || /z$/i.test(pathData));
181
+ if (closedPath && color && !isGradient(color) && fillColor === undefined && strokeColor === undefined) {
182
+ params.fillColor = color;
183
+ }
91
184
  // Handle simple solid colors in params
92
- if (color && !isGradient(color)) {
185
+ if (color && !isGradient(color) && !noFill(color)) {
93
186
  params.color = color;
94
187
  }
95
- if (fillColor && !isGradient(fillColor)) {
188
+ if (fillColor && !isGradient(fillColor) && !noFill(fillColor)) {
96
189
  params.fillColor = fillColor;
97
190
  }
98
191
  if (strokeColor && !isGradient(strokeColor)) {
@@ -126,28 +219,17 @@ function generateCreateItemCode(itemType, position, properties) {
126
219
  // ${itemType} — a render-time surface. Drawn per pixel by the cloud renderer;
127
220
  // stood in for locally by a flat plate so the layout still reads.
128
221
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
129
- if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
130
- }
131
- // RTL IS NOT SUPPORTED, AND SILENCE WAS THE WORST WAY TO SAY SO.
132
- //
133
- // create('text', {direction:'rtl'}) dropped the property with no error and
134
- // left the base direction LTR, so an Arabic sentence's final period landed
135
- // at the reader's START — visibly wrong, and attributable to nothing. The
136
- // engine has no text-direction support at all: `direction` there is an arc
137
- // sweep and an animation direction, and `justification` is the only
138
- // text-related alignment it reads.
139
- //
140
- // Refused by name rather than accepted-and-dropped, and rather than adding a
141
- // prop that would be dropped one layer further on. The alignment half is
142
- // real and is named, because it is what a caller can actually use today.
143
- const rtlAsked = params.direction ?? params.textDirection;
144
- if (rtlAsked !== undefined) {
145
- return `
146
- // Create ${itemType} — refused, rather than silently left ltr
147
- ({ success: false, error: ${JSON.stringify(`direction / textDirection is not supported: this studio has no text-direction support, so ${String(rtlAsked)} would have been accepted and the text left LTR. `
148
- + 'That is visible as a right-to-left sentence whose final punctuation lands at the start. '
149
- + "You can align with justification: 'right', which is read, but the BASE direction needs engine support — "
150
- + 'pass pre-shaped text, or split the line, until it lands.')} });`.trim();
222
+ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
223
+ // The snippet's value is its last statement's, which was the assignment above:
224
+ // the caller got renderParams back and no itemId to address the item by.
225
+ // ONE outer paren pair: the governor's wrapTrailingReturn reads a top-level
226
+ // \`}\` as a statement end, so a bare ternary of object literals is not wrapped
227
+ // and its value is lost.
228
+ ((item && item.data)
229
+ ? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} },
230
+ localStandIn: true,
231
+ note: 'drawn here as a flat plate; the ${itemType} itself appears only in a cloud render. A local export shows the plate.' }
232
+ : { success: false, error: 'the stand-in plate for this ${itemType} was not created.' });`;
151
233
  }
152
234
  // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
153
235
  //
@@ -172,6 +254,25 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
172
254
  if (params.origin !== undefined)
173
255
  params.origin = normalized;
174
256
  }
257
+ // AN UNREAD PROPERTY IS DROPPED WITHOUT A WORD — SO SAY THE WORD HERE.
258
+ //
259
+ // `properties` is free-form and create() returns an item whatever it is
260
+ // given, so {blur: 12, backdropFilter: 'blur(8px)'} produced a crisp shape and
261
+ // a successful call; the caller learned only by rendering. The allowlist is
262
+ // generated from the engine (what create() and normalizeParams() read), and
263
+ // the handful this emitter consumes itself are added. Reported back, not
264
+ // refused: the item is still what the caller asked for in every other way,
265
+ // and a refusal would cost a re-issue over one typo.
266
+ //
267
+ // The generated allowlist says what create() READS, for any type; the text
268
+ // keys it reads apply only to text (the engine gates them on PointText). So
269
+ // on another type they are reported whatever the list says. `direction` is
270
+ // not among them: an arc reads it as its sweep.
271
+ const textOnly = (k) => TEXT_STYLE_KEYS.includes(k) || k === 'fit' || k === 'textDirection' || k === 'dir'
272
+ || TABULAR_KEYS.includes(k) || k === 'strokePosition';
273
+ const ignored = Object.keys(properties).filter((k) => itemType === 'text'
274
+ ? !CREATE_KNOWN_KEYS.has(k) && !textOnly(k) && !DIRECTION_KEYS.includes(k)
275
+ : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
175
276
  // Build the code
176
277
  let code = `
177
278
  // Create ${itemType} item
@@ -195,12 +296,51 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
195
296
  if (blendMode) {
196
297
  code += '\n' + generateBlendModeCode('item', blendMode);
197
298
  }
299
+ if (clearFill)
300
+ code += `\nitem.fillColor = null;`;
301
+ // DASHES, CAPS AND JOINS FOR EVERY SHAPE (round 7 Z, 1.54). create() applies
302
+ // them only in its path / line / pattern branches; the shared tail sets a
303
+ // stroke's colour and width for every type but not these, so a dashed circle
304
+ // rendered solid and nothing said so (the key is on create()'s read list, so
305
+ // the unread-property report could not catch it). Re-applied here for every
306
+ // type; on a path it repeats the engine's own value.
307
+ const strokeStyle = [];
308
+ if (Array.isArray(properties.dashArray))
309
+ strokeStyle.push(`item.dashArray = ${JSON.stringify(properties.dashArray)};`);
310
+ if (typeof properties.strokeCap === 'string')
311
+ strokeStyle.push(`item.strokeCap = ${JSON.stringify(properties.strokeCap)};`);
312
+ if (typeof properties.strokeJoin === 'string')
313
+ strokeStyle.push(`item.strokeJoin = ${JSON.stringify(properties.strokeJoin)};`);
314
+ if (strokeStyle.length)
315
+ code += `\n${strokeStyle.join('\n')}`;
198
316
  // Add opacity support
199
317
  if (opacity !== undefined) {
200
318
  code += `\nitem.opacity = ${opacity};`;
201
319
  }
320
+ if (itemType === 'text')
321
+ code += emitTextStyle('item', properties);
322
+ // The vertical hold follows the anchor, as the engine's own create step
323
+ // derives it (1.90): without it the fit held the centre, and a top-left
324
+ // anchored headline's top edge drifted on every refit.
325
+ if (textDirection)
326
+ code += emitDirectionCheck('item', textDirection);
327
+ if (textTabular)
328
+ code += emitTabularCheck('item');
329
+ if (textStrokePos)
330
+ code += emitStrokePositionCheck('item', textStrokePos);
331
+ if (textFit)
332
+ code += `\nlet __textFit = null;${emitTextFit('item', textFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null))}`;
333
+ // After opacity, so the "on" level of the lifetime is the item's own.
334
+ const hasLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
335
+ if (hasLifetime) {
336
+ code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt, properties.ttl)}`;
337
+ }
202
338
  // Ensure item is visible above backgrounds/generators
203
339
  code += `\nif (item.bringToFront) item.bringToFront();`;
340
+ // selectable / isDraggable / isDecorative live on item.data, not in params.
341
+ if (dataFlags && Object.keys(dataFlags).length > 0) {
342
+ code += `\nif (item.data) Object.assign(item.data, ${JSON.stringify(dataFlags)});`;
343
+ }
204
344
  // Add dynamic content support for text items
205
345
  if (contentType && itemType === 'text') {
206
346
  const opts = {};
@@ -212,19 +352,440 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
212
352
  opts.countdownEndText = countdownEndText;
213
353
  code += `\nif (app.setDynamicContent) app.setDynamicContent(item, '${contentType}', ${JSON.stringify(opts)});`;
214
354
  }
355
+ const loadsFont = itemType === 'text' && typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
215
356
  code += `
216
357
  const itemId = item.data.registryId;
217
358
  app.historyManager.saveState();
218
359
 
219
360
  // Return item info
220
- ({ itemId, type: '${itemType}', position: { x: ${position.x}, y: ${position.y} } });`;
361
+ ({ itemId, type: '${itemType}'${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${omitXY ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
362
+ ? `, ignoredProperties: ${JSON.stringify(ignored)}, warning: ${JSON.stringify(`${ignored.join(', ')} ${ignored.length > 1 ? 'are' : 'is'} not read when creating an item, so ${ignored.length > 1 ? 'they had' : 'it had'} no effect. `
363
+ + 'Check the spelling against this item type\'s documented properties.')}`
364
+ : ''} });`;
365
+ // Loading a font is async, and it has to happen BEFORE create() measures the
366
+ // text — so only this case becomes an async IIFE; every other create stays a
367
+ // plain snippet ending in its (-led result.
368
+ if (loadsFont) {
369
+ const body = code.trim().replace(/\(\{ itemId([\s\S]*)\}\);$/, 'return { itemId$1 };');
370
+ return `(async function() {${emitEnsureFont(properties.fontFamily)}\n${body}\n})();`;
371
+ }
221
372
  return code.trim();
222
373
  }
374
+ /**
375
+ * Every `properties` key modify_item can act on: what the engine's modify path
376
+ * and normalizeParams() read (generated), plus what generateModifyItemCode
377
+ * applies itself (dynamic content, audio level, lifetime, raster smoothing,
378
+ * the text properties modifyItem does not pass on).
379
+ */
380
+ const MODIFY_KNOWN_KEYS = new Set([
381
+ ...MODIFY_CHANGE_READS,
382
+ ...NORMALIZE_PARAM_READS,
383
+ 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
384
+ 'audioGain', 'volume', 'gain', 'bornAt', 'ttl', 'smoothing',
385
+ 'fontStyle', 'leading', 'lineHeight', 'skewX', 'skewY', 'matrix', 'fit', 'direction', 'textDirection', 'dir', 'tabularFigures', 'fontVariantNumeric', 'fontFeatures', 'strokePosition',
386
+ ]);
387
+ /** Spellings callers reach for, and the key the engine actually reads. */
388
+ const MODIFY_DID_YOU_MEAN = {
389
+ scaling: 'scale', size: 'width / height', rotate: 'rotation', angle: 'rotation', shear: 'skewX / skewY', skew: 'skewX / skewY',
390
+ alpha: 'opacity', text: 'content', stroke_width: 'strokeWidth', lineWidth: 'strokeWidth',
391
+ };
392
+ /**
393
+ * Every `properties` key create_item can act on: what the engine's create()
394
+ * and normalizeParams() read (generated — see shape-params.ts), plus the keys
395
+ * generateCreateItemCode consumes itself before the engine sees them.
396
+ */
397
+ const CREATE_KNOWN_KEYS = new Set([
398
+ ...ACCEPTED_CREATE_PARAMS,
399
+ ...NORMALIZE_PARAM_READS,
400
+ // withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
401
+ // setDynamicContent for text.
402
+ 'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
403
+ // Lifetimes. The editor's create() does not read them, but they are
404
+ // documented on ANY item and the scene renderer schedules by them — calling
405
+ // them "no effect" would steer a caller off the only way to cut between shots.
406
+ 'bornAt', 'ttl',
407
+ ]);
408
+ /**
409
+ * Load a font family before text is drawn with it, and say if it still is not there.
410
+ *
411
+ * `font list_available` names ~60 families, and drawing with one (Anton,
412
+ * Playfair Display) came out in a serif fallback with a console warning
413
+ * nobody over MCP can read. They are real — the studio's decorative Google
414
+ * Fonts sheet carries them — but it is fetched only when the editor's own UI
415
+ * asks, and text measures its bounds at creation, so a face arriving later
416
+ * leaves the layout measured in the fallback.
417
+ *
418
+ * So: ask checkFont; if missing, ensureFontsLoaded('all') and force the fetch
419
+ * with document.fonts.load (a registered @font-face is not a fetched one),
420
+ * then ask again. `__font` is set only when the first answer was "missing",
421
+ * so an available font adds nothing to the result. Needs an async context.
422
+ */
423
+ export function emitEnsureFont(family) {
424
+ const primary = family.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
425
+ return `
426
+ let __font = null;
427
+ if (typeof app.checkFont === 'function') {
428
+ const __v0 = app.checkFont(${JSON.stringify(family)});
429
+ if (__v0 && __v0.available === false) {
430
+ if (typeof app.ensureFontsLoaded === 'function') { try { await app.ensureFontsLoaded('all'); } catch (_) { /* reported below */ } }
431
+ try { if (typeof document !== 'undefined' && document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
432
+ let __v1 = app.checkFont(${JSON.stringify(family)});
433
+ let __via = 'studio';
434
+ // Not in the studio's own sheets (Monoton, a common neon face, is not):
435
+ // the page's CSP allows fonts.googleapis.com stylesheets and
436
+ // fonts.gstatic.com files, so ask Google Fonts for the family directly.
437
+ if (!(__v1 && __v1.available === true) && typeof document !== 'undefined' && document.head) {
438
+ __via = 'google-fonts';
439
+ await new Promise(function(res) {
440
+ const id = 'pp-gf-' + ${JSON.stringify(primary)}.replace(/[^a-z0-9]+/gi, '-');
441
+ if (document.getElementById(id)) return res();
442
+ const l = document.createElement('link');
443
+ l.id = id; l.rel = 'stylesheet';
444
+ l.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(${JSON.stringify(primary)}).replace(/%20/g, '+') + '&display=swap';
445
+ l.onload = res; l.onerror = res; setTimeout(res, 5000);
446
+ document.head.appendChild(l);
447
+ });
448
+ try { if (document.fonts) await document.fonts.load('16px ' + ${JSON.stringify(JSON.stringify(primary))}); } catch (_) { /* reported below */ }
449
+ __v1 = app.checkFont(${JSON.stringify(family)});
450
+ }
451
+ __font = (__v1 && __v1.available === true)
452
+ ? { family: ${JSON.stringify(primary)}, available: true, loadedNow: true, via: __via }
453
+ : { family: ${JSON.stringify(primary)}, available: false,
454
+ warning: ${JSON.stringify(primary)} + ' could not be loaded, so this text is drawn in a fallback face. '
455
+ + ((__v1 && __v1.reason) || '') + ' font list_available names the families this studio can load.' };
456
+ }
457
+ }`;
458
+ }
459
+ /**
460
+ * Auto-fit a text item to a box (FxTool 661224ef: app.fitText). Text that
461
+ * overran its slot — a German translation, a long name on a certificate, a
462
+ * feed value in a variant matrix — was the #1 blocker in rounds 6–9. The box
463
+ * is remembered by the engine, so later content changes refit. On a studio
464
+ * without fitText the result says so rather than leaving the text as it was
465
+ * in silence. `__textFit` must be declared by the caller.
466
+ */
467
+ /**
468
+ * A text paragraph's base direction (FxTool a3b4e7c3, gap 1.16): 'auto' (the
469
+ * first strong character decides), 'ltr' or 'rtl', under three spellings.
470
+ *
471
+ * Pulled out of the changes and normalised to `direction`, the one spelling
472
+ * the engine reads on both create and modify (modify does not read `dir`). A
473
+ * value that is not a direction is refused here: the engine only console.warns
474
+ * it, which production strips.
475
+ */
476
+ const DIRECTION_KEYS = ['direction', 'textDirection', 'dir'];
477
+ // The engine's rule (FxTool js/core/TextDirection.js, UAX #9 P2/P3): the
478
+ // first STRONG character decides. Mirrored so a result can say which way
479
+ // 'auto' went.
480
+ const RTL_SCRIPT = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}]/u;
481
+ function baseDirection(text) {
482
+ for (const ch of text) {
483
+ if (RTL_SCRIPT.test(ch))
484
+ return 'rtl';
485
+ if (/\p{L}/u.test(ch))
486
+ return 'ltr';
487
+ }
488
+ return 'ltr';
489
+ }
490
+ function takeDirection(props) {
491
+ const key = DIRECTION_KEYS.find((k) => props[k] !== undefined && props[k] !== null && props[k] !== '');
492
+ const raw = key ? props[key] : undefined;
493
+ for (const k of DIRECTION_KEYS)
494
+ delete props[k];
495
+ if (raw === undefined)
496
+ return null;
497
+ const v = String(raw).trim().toLowerCase();
498
+ if (v === 'auto' || v === 'ltr' || v === 'rtl') {
499
+ props.direction = v;
500
+ return { value: v };
501
+ }
502
+ return { error: `direction ${JSON.stringify(String(raw))} is not one of auto, ltr, rtl — the text keeps its current direction.` };
503
+ }
504
+ /**
505
+ * Sets `__direction` (declared here) to whether the studio took the direction:
506
+ * an engine without RTL support accepts the key and draws left-to-right, so
507
+ * the only evidence is the value it records on the item.
508
+ */
509
+ function emitDirectionCheck(itemExpr, req) {
510
+ if ('error' in req)
511
+ return `
512
+ const __direction = ${JSON.stringify({ applied: false, error: req.error })};`;
513
+ const v = JSON.stringify(req.value);
514
+ return `
515
+ const __direction = (function(it) {
516
+ if (!it || it.className !== 'PointText') return { applied: false, note: 'direction applies to text items only.' };
517
+ return it.data && it.data.direction === ${v}
518
+ ? { applied: true, value: ${v}${req.resolved ? `, resolved: ${JSON.stringify(req.resolved)}` : ''} }
519
+ : { applied: false, note: 'this studio does not set a paragraph direction (it needs an engine with RTL text support): the text is laid out left-to-right.' };
520
+ })(${itemExpr});`;
521
+ }
522
+ /**
523
+ * Tabular (fixed-width) figures (FxTool 3354d51c): a counter or price column
524
+ * that does not jitter as its digits change. The engine reads all three
525
+ * spellings on create and modify itself; this only reports whether it took
526
+ * them, because an engine without them accepts the key and draws
527
+ * proportional digits.
528
+ */
529
+ const TABULAR_KEYS = ['tabularFigures', 'fontVariantNumeric', 'fontFeatures'];
530
+ function wantsTabular(p) {
531
+ if (!TABULAR_KEYS.some((k) => p[k] !== undefined))
532
+ return null;
533
+ if (p.tabularFigures === true)
534
+ return true;
535
+ if (typeof p.fontVariantNumeric === 'string' && /tabular-nums/.test(p.fontVariantNumeric))
536
+ return true;
537
+ const f = p.fontFeatures;
538
+ const list = Array.isArray(f) ? f.map(String) : typeof f === 'string' ? [f] : f && typeof f === 'object' ? Object.keys(f).filter((k) => f[k]) : [];
539
+ return list.some((x) => /\btnum\b/.test(x));
540
+ }
541
+ function emitTabularCheck(itemExpr) {
542
+ return `
543
+ const __tabular = (function(it) {
544
+ if (!it || it.className !== 'PointText') return { applied: false, note: 'tabular figures apply to text items only.' };
545
+ return it.data && it.data.tabularFigures === true
546
+ ? { applied: true }
547
+ : { applied: false, note: 'this studio does not lay out tabular figures (it needs an engine with them): digits keep their proportional widths. A monospaced font is the fallback.' };
548
+ })(${itemExpr});`;
549
+ }
550
+ function takeStrokePosition(props, allowNull) {
551
+ if (!('strokePosition' in props))
552
+ return null;
553
+ const raw = props.strokePosition;
554
+ if (raw === null && allowNull) {
555
+ props.strokePosition = 'center';
556
+ return { value: 'center' };
557
+ }
558
+ const v = String(raw ?? '').trim().toLowerCase();
559
+ if (v === 'outside') {
560
+ props.strokePosition = 'outside';
561
+ return { value: 'outside' };
562
+ }
563
+ if (v === 'center' || v === 'centre') {
564
+ props.strokePosition = 'center';
565
+ return { value: 'center' };
566
+ }
567
+ delete props.strokePosition;
568
+ return { error: `strokePosition ${JSON.stringify(raw)} is not 'outside' or 'center' — the stroke is unchanged.` };
569
+ }
570
+ function emitStrokePositionCheck(itemExpr, req) {
571
+ if ('error' in req)
572
+ return `\nconst __strokePos = ${JSON.stringify({ applied: false, error: req.error })};`;
573
+ if (req.value === 'center')
574
+ return `\nconst __strokePos = { applied: true, value: 'center' };`;
575
+ return `
576
+ const __strokePos = (function(it) {
577
+ if (!it || it.className !== 'PointText') return { applied: false, note: 'strokePosition applies to text items only.' };
578
+ if (!(it.data && it.data.strokePosition === 'outside')) return { applied: false, note: 'this studio does not draw text strokes outside the glyph (it needs an engine with strokePosition): the stroke is centred on the outline, so a thick stroke thins the letters.' };
579
+ return (it.strokeColor && it.strokeWidth > 0) ? { applied: true, value: 'outside' } : { applied: true, value: 'outside', note: 'the text has no stroke yet: set strokeColor and strokeWidth to see it.' };
580
+ })(${itemExpr});`;
581
+ }
582
+ /** 'top' / 'bottom' from a top-* / bottom-* anchor, as the engine derives it. */
583
+ function anchorHold(anchor) {
584
+ const a = String(anchor ?? '').toLowerCase();
585
+ return /^top/.test(a) ? 'top' : /^bottom/.test(a) ? 'bottom' : undefined;
586
+ }
587
+ function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
588
+ return `
589
+ if (typeof app.fitText === 'function') {
590
+ const __fbox = ${JSON.stringify(box)};
591
+ if (__fbox.hold === undefined) { const __h = ${holdFallback}; if (__h) __fbox.hold = __h; }
592
+ const __fr = app.fitText(${itemExpr}, __fbox);
593
+ __textFit = __fr && __fr.ok !== false
594
+ ? Object.assign({ applied: true }, __fr, __fr.fits === false ? { warning: 'the text does not fit its box even at the minimum font size — shorten it or widen the box.' } : {})
595
+ : { applied: false, error: (__fr && __fr.error) || 'the studio refused the fit box' };
596
+ } else {
597
+ __textFit = { applied: false, note: 'this studio cannot auto-fit text (no app.fitText): the text keeps its font size and may overrun the box.' };
598
+ }`;
599
+ }
600
+ /**
601
+ * Paper text properties the engine's create() and modifyItem() never pass on.
602
+ *
603
+ * Line spacing, weight and italic are what editorial, Swiss and zine layouts
604
+ * are made of, and all three were dropped without a word: create('text')
605
+ * builds a PointText from content / fontFamily / fontSize / justification
606
+ * only. PointText itself supports them (`leading`, and `fontWeight`, which
607
+ * Paper writes into the canvas font shorthand — so italic rides in it as
608
+ * "italic 700"). Set after the fact, then re-anchored to the edge the engine
609
+ * anchored to, because a heavier weight or taller leading moves the bounds.
610
+ *
611
+ * lineHeight is the CSS habit: <= 4 is a multiple of fontSize, above that px.
612
+ */
613
+ const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight'];
614
+ function emitTextStyle(itemExpr, props) {
615
+ const weight = props.fontWeight;
616
+ const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
617
+ const lh = typeof props.lineHeight === 'number' ? props.lineHeight : undefined;
618
+ const leading = typeof props.leading === 'number' ? props.leading : undefined;
619
+ if (weight === undefined && props.fontStyle === undefined && leading === undefined && lh === undefined)
620
+ return '';
621
+ return `
622
+ (function(it) {
623
+ if (!it || it.className !== 'PointText') return;
624
+ const j = it.justification;
625
+ const edge = function(b) { return j === 'left' ? b.left : j === 'right' ? b.right : b.center.x; };
626
+ const b0 = it.bounds, ax = edge(b0), ay = b0.center.y;
627
+ ${weight !== undefined || props.fontStyle !== undefined
628
+ ? `it.fontWeight = ${JSON.stringify(`${italic ? 'italic ' : ''}${weight ?? 'normal'}`)};`
629
+ : ''}
630
+ ${leading !== undefined ? `it.leading = ${leading};` : ''}
631
+ ${lh !== undefined ? `it.leading = ${lh} <= 4 ? ${lh} * it.fontSize : ${lh};` : ''}
632
+ const b1 = it.bounds;
633
+ it.position = it.position.add([ax - edge(b1), ay - b1.center.y]);
634
+ })(${itemExpr});`;
635
+ }
636
+ /**
637
+ * Which data keys a chart plots, when the caller did not say.
638
+ *
639
+ * ChartSystem defaults to fields named `x` and `y`. The tool's own documented
640
+ * example is [{category, value}], so that example drew NO bars — index labels
641
+ * "0 1 2 3 4" and success:true, dataPoints:5 (round 7 Z, 1.53). Inferred here
642
+ * from the data instead: x is a label-like key (category/label/name/…, else
643
+ * the first text key), y is value-like (value/count/…, else the first number
644
+ * key other than x). stackedBar picks its own series from every numeric key,
645
+ * so only x is inferred for it. A y that cannot be found is refused by name,
646
+ * listing the keys that were there, rather than drawn as nothing.
647
+ */
648
+ function resolveChartFields(chartType, data, options) {
649
+ // No rows yet (a chart created empty and filled by update): nothing to infer.
650
+ if (data.length === 0)
651
+ return { options };
652
+ const row = data.find((d) => d && typeof d === 'object') ?? {};
653
+ const keys = Object.keys(row);
654
+ const out = { ...options };
655
+ const inferred = {};
656
+ const has = (k) => keys.includes(k);
657
+ if (out.xField === undefined && !has('x')) {
658
+ const x = ['category', 'label', 'name', 'date', 'month', 'year', 'key'].find(has)
659
+ ?? keys.find((k) => typeof row[k] === 'string') ?? keys[0];
660
+ if (x !== undefined) {
661
+ out.xField = x;
662
+ inferred.xField = x;
663
+ }
664
+ }
665
+ const xf = out.xField ?? 'x';
666
+ if (chartType !== 'stackedBar' && out.yField === undefined && !has('y')) {
667
+ const numeric = keys.filter((k) => k !== xf && typeof row[k] === 'number');
668
+ const y = ['value', 'count', 'amount', 'total', 'score'].find((k) => numeric.includes(k)) ?? numeric[0];
669
+ if (y === undefined) {
670
+ return { error: `no numeric field to plot: the data rows have ${keys.length ? keys.join(', ') : 'no keys'}, and none but ${xf} is a number. Pass options.yField naming the numeric key.` };
671
+ }
672
+ out.yField = y;
673
+ inferred.yField = y;
674
+ }
675
+ return Object.keys(inferred).length ? { options: out, inferred } : { options: out };
676
+ }
677
+ /**
678
+ * A lifetime (bornAt / ttl, seconds) as a VISIBILITY WINDOW, [bornAt, bornAt + ttl).
679
+ *
680
+ * THE ENGINE HAS NO LIFETIME, and the first answer here (5bfc480) wrote one as
681
+ * hard-cut opacity keys merged into the item's own track. That failed four
682
+ * ways once items also had keyframes (ads production): keyframe_animate
683
+ * replaced the track and the ttl cut was lost (1.83); opacity-only cut keys
684
+ * broke the position track through the engine's missing-property rule (2.30 →
685
+ * 1.84); authored opacity and the cuts blended instead of multiplying (1.85);
686
+ * and exact-time hand-offs left a blank frame (1.86).
687
+ *
688
+ * So it is no longer keys. ONE frame callback ('pp_lifetimes') sets
689
+ * item.visible from the window for every item with a lifetime, reading each
690
+ * item's live data.bornAt / ttl, from the frame's own time: event.sceneTime,
691
+ * which both the export's step and sceneAt supply, else app.playbackTime. The
692
+ * export runs frame callbacks inside app.update() before it draws each step
693
+ * (VideoExporter._advanceToTime), so exports honour it. visible, not opacity,
694
+ * so it composes with any keyframes — the engine's own clip window hides the
695
+ * same way for the same reason. A 1-microsecond tolerance makes a hand-off at
696
+ * an exact frame time show exactly one item.
697
+ *
698
+ * Runtime-only: a scene saved and reopened keeps data.bornAt / ttl (the cloud
699
+ * renderer reads those) but not the callback, until the studio reads the
700
+ * fields itself — the result says so. Lifetimes applied the old way are
701
+ * migrated: their tagged opacity keys are removed.
702
+ */
703
+ function emitLifetime(itemExpr, bornAt, ttl) {
704
+ const b = Number(bornAt);
705
+ const t = Number(ttl);
706
+ const born = Number.isFinite(b) && b > 0 ? b : 0;
707
+ const span = Number.isFinite(t) && t > 0 ? t : null;
708
+ return `
709
+ (function(it) {
710
+ if (!it || !it.data) return;
711
+ it.data.bornAt = ${born};
712
+ ${span === null ? 'delete it.data.ttl;' : `it.data.ttl = ${span};`}
713
+ if (Array.isArray(it.data.keyframes) && it.data.keyframes.some(function(k) { return k && k._lifetime; })) {
714
+ it.data.keyframes = it.data.keyframes.filter(function(k) { return !(k && k._lifetime); });
715
+ }
716
+ // THE STUDIO'S OWN LIFETIME, WHEN IT HAS ONE (FxTool bf1a656b): same window,
717
+ // same tolerance, applied in the frame loop, on scrub and in export — and it
718
+ // PERSISTS through save / reload, which the callback below cannot. Passed
719
+ // straight through; the callback is only the fallback for older studios.
720
+ if (typeof app.setLifetime === 'function') {
721
+ const __r = app.setLifetime(it, ${born}, ${span === null ? 'null' : span});
722
+ if (!__r || __r.ok === false) {
723
+ __lifetime = { bornAt: ${born}, ttl: ${span}, applied: false, error: (__r && __r.error) || 'the studio refused the lifetime' };
724
+ return;
725
+ }
726
+ const __G = globalThis.__ppMcp;
727
+ if (__G && __G.lifetimeApp === app && __G.lifetimeIds) __G.lifetimeIds.delete(it.data.registryId || it.data.id);
728
+ __lifetime = { bornAt: ${born}, ttl: ${span}, applied: true, via: 'engine', persists: true };
729
+ return;
730
+ }
731
+ const rid = it.data.registryId || it.data.id;
732
+ // MCP-owned page state lives on globalThis, not on app: it is not an engine
733
+ // member, and the engine-surface guard reads app.X as a claim that it is.
734
+ const __pp = globalThis.__ppMcp = globalThis.__ppMcp || {};
735
+ // Keyed to THIS studio: a re-initialised app has none of the old one's
736
+ // callbacks, so the registration and the id set start again with it.
737
+ if (__pp.lifetimeApp !== app) { __pp.lifetimeApp = app; __pp.lifetimeIds = new Set(); }
738
+ if (typeof app.addOnFrameCallback !== 'function' || !rid) {
739
+ __lifetime = { bornAt: ${born}, ttl: ${span}, applied: false, note: 'stored on the item, but this studio has no frame callbacks, so it shows for the whole local render (a cloud render honours it).' };
740
+ return;
741
+ }
742
+ __pp.lifetimeIds = __pp.lifetimeIds || new Set();
743
+ __pp.lifetimeIds.add(rid);
744
+ const within = function(x, now) {
745
+ const b0 = x.data.bornAt, t0 = x.data.ttl;
746
+ return now >= b0 - 1e-6 && (typeof t0 !== 'number' || now < b0 + t0 - 1e-6);
747
+ };
748
+ // The visibility pass itself, kept where a still export can call it too
749
+ // (a seek alone does not run frame callbacks).
750
+ __pp.applyLifetimes = function(now) {
751
+ __pp.lifetimeIds.forEach(function(id) {
752
+ const e = app.itemRegistry && app.itemRegistry.get(id);
753
+ const x = e && e.item;
754
+ if (!x || !x.data || typeof x.data.bornAt !== 'number') { __pp.lifetimeIds.delete(id); return; }
755
+ const vis = within(x, now);
756
+ if (x.visible !== vis) x.visible = vis;
757
+ });
758
+ };
759
+ // Registered EVERY time, not once behind a flag: clearCanvas / start_job
760
+ // remove frame callbacks, and a stale "already registered" flag left every
761
+ // later scene's lifetimes doing nothing (retest of 28a4a0e). Re-adding the
762
+ // same id replaces it.
763
+ app.addOnFrameCallback('pp_lifetimes', function(ev) {
764
+ const now = ev && typeof ev.sceneTime === 'number' ? ev.sceneTime : (typeof app.playbackTime === 'number' ? app.playbackTime : 0);
765
+ __pp.applyLifetimes(now);
766
+ });
767
+ it.visible = within(it, typeof app.playbackTime === 'number' ? app.playbackTime : 0);
768
+ __lifetime = { bornAt: ${born}, ttl: ${span}, applied: true, via: 'visibility',
769
+ note: 'applied as visibility, so it composes with keyframes. Runtime-only until the studio reads bornAt / ttl itself: a scene reopened in the editor keeps the fields (and a cloud render honours them) but must be re-timed to play locally.' };
770
+ if (it.data.clipInPoint !== undefined || it.data.clipOutPoint !== undefined) {
771
+ __lifetime.warning = 'this item also has a clip window (clipInPoint / clipOutPoint), which hides it by the same means — the two can fight. Use one.';
772
+ }
773
+ })(${itemExpr});`;
774
+ }
223
775
  /**
224
776
  * Template for modifying items
225
777
  */
226
- function generateModifyItemCode(itemId, properties) {
778
+ function generateModifyItemCode(itemId, properties, dataFlags) {
227
779
  const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
780
+ // A new fit box is applied here (emitTextFit, with the hold carried over),
781
+ // not by modifyItem: the engine's modify fits it with no hold, overwriting
782
+ // the one this reads back. fit: null still goes through — that is the
783
+ // engine's "stop fitting".
784
+ if (restProperties.fit && typeof restProperties.fit === 'object')
785
+ delete restProperties.fit;
786
+ const modDirection = takeDirection(restProperties);
787
+ const modTabular = wantsTabular(restProperties) === true;
788
+ const modStrokePos = takeStrokePosition(restProperties, true);
228
789
  // modifyItem(id, changes), NOT select() + modify().
229
790
  //
230
791
  // modify() operates on the SELECTION, so this selected an id and then edited
@@ -269,11 +830,101 @@ const entry = app.itemRegistry.get('${itemId}');
269
830
  if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, '${contentType}', ${JSON.stringify(opts)});`;
270
831
  }
271
832
  }
833
+ // AN AUDIO ITEM'S LEVEL IS NOT AN ITEM PROPERTY. modifyItem applies changes
834
+ // to the Paper item (an audio item's is an invisible sentinel), so
835
+ // {audioGain} / {volume} answered success and moved nothing (measured 0.0 dB).
836
+ // The mix reads registry properties.audioGain; the live player has its own
837
+ // volume. Both are set, and gain is kept in step for the editor.
838
+ const levelKey = ['audioGain', 'volume', 'gain'].find((k) => typeof properties[k] === 'number');
839
+ if (levelKey) {
840
+ const level = Math.max(0, Math.min(1, properties[levelKey]));
841
+ code += `
842
+ const _ae = app.itemRegistry.get('${itemId}');
843
+ if (_ae && _ae.type === 'audio') {
844
+ _ae.properties = _ae.properties || {};
845
+ _ae.properties.audioGain = ${level};
846
+ _ae.properties.gain = ${level};
847
+ const _A = (typeof window !== 'undefined') && window.PinePaperAgent;
848
+ const _m = _A && typeof _A.listMedia === 'function' ? _A.listMedia().find(function(x) { return x.registryId === '${itemId}'; }) : null;
849
+ if (_m && app.audioLayer && typeof app.audioLayer.setVolume === 'function') app.audioLayer.setVolume(${level}, _m.id);
850
+ }`;
851
+ }
852
+ // Raster resampling (pixel art: 'off'). A Paper Raster property modifyItem
853
+ // does not pass on; applied to a raster, or to the raster an image group holds.
854
+ if (typeof properties.smoothing === 'string') {
855
+ code += `
856
+ const _sm = app.itemRegistry.get('${itemId}');
857
+ const _smR = _sm && _sm.item && (_sm.item.className === 'Raster' ? _sm.item
858
+ : (typeof _sm.item.getItem === 'function' ? _sm.item.getItem({ className: 'Raster' }) : null));
859
+ if (_smR) _smR.smoothing = ${JSON.stringify(properties.smoothing)};`;
860
+ }
861
+ // No fill on modify too: null / 'transparent' / 'none' clear it outright,
862
+ // after modifyItem, which would otherwise read 'transparent' as black.
863
+ const fillNone = (v) => v === null || (typeof v === 'string' && /^(transparent|none)$/i.test(v.trim()));
864
+ if (fillNone(properties.fillColor) || (properties.fillColor === undefined && properties.color !== undefined && fillNone(properties.color))) {
865
+ code += `
866
+ const _nf = app.itemRegistry.get('${itemId}');
867
+ if (_nf && _nf.item) _nf.item.fillColor = null;`;
868
+ }
869
+ // SKEW AND AFFINE, FOR MOCKUPS (round 9 HH). A box face or a phone screen in
870
+ // perspective-ish mockups is a shear / affine map; modify_item reported
871
+ // skewX / shear / matrix as ignored and the only route was custom code.
872
+ // Paper does both: item.shear(tan x, tan y) about the centre, and
873
+ // item.transform(Matrix). They are APPLIED to the current shape — a second
874
+ // call compounds — not stored as settable properties; the result says so.
875
+ const skewX = typeof properties.skewX === 'number' ? properties.skewX : 0;
876
+ const skewY = typeof properties.skewY === 'number' ? properties.skewY : 0;
877
+ const matrix = Array.isArray(properties.matrix) && properties.matrix.length === 6 && properties.matrix.every((n) => typeof n === 'number')
878
+ ? properties.matrix : null;
879
+ const affine = skewX !== 0 || skewY !== 0 || matrix !== null;
880
+ if (affine) {
881
+ code += `
882
+ const _af = app.itemRegistry.get('${itemId}');
883
+ const _afi = _af && _af.item;
884
+ if (_afi) {
885
+ ${skewX !== 0 || skewY !== 0 ? `_afi.shear(Math.tan(${skewX} * Math.PI / 180), Math.tan(${skewY} * Math.PI / 180), _afi.bounds.center);` : ''}
886
+ ${matrix ? `_afi.transform(new paper.Matrix(${matrix.join(', ')}));` : ''}
887
+ }`;
888
+ }
889
+ const modFit = properties.fit && typeof properties.fit === 'object' ? properties.fit : null;
890
+ if (modFit) {
891
+ code += `
892
+ let __textFit = null;
893
+ const _tf = app.itemRegistry.get('${itemId}');${emitTextFit('_tf && _tf.item', modFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null) + " || (_tf && _tf.item && _tf.item.data && _tf.item.data.fitBox && _tf.item.data.fitBox.hold)")}`;
894
+ }
895
+ const textStyle = emitTextStyle('_ts && _ts.item', properties);
896
+ if (textStyle) {
897
+ code += `
898
+ const _ts = app.itemRegistry.get('${itemId}');${textStyle}`;
899
+ }
900
+ const modLifetime = properties.bornAt !== undefined || properties.ttl !== undefined;
901
+ if (modLifetime) {
902
+ code += `
903
+ let __lifetime = null;
904
+ const _lt = app.itemRegistry.get('${itemId}');${emitLifetime('_lt && _lt.item', properties.bornAt, properties.ttl)}`;
905
+ }
906
+ if (dataFlags && Object.keys(dataFlags).length > 0) {
907
+ code += `
908
+ const _flagged = app.itemRegistry.get('${itemId}');
909
+ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item.data, ${JSON.stringify(dataFlags)});`;
910
+ }
911
+ const modFont = typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
912
+ // AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
913
+ // raster answered success and left it 16 px; the engine reads `scale`. Same
914
+ // report create_item gives, against the engine's modify path (generated).
915
+ const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k));
916
+ const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
917
+ + modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
272
918
  code += `
273
919
  app.historyManager.saveState();
274
- return { success: true, itemId: '${itemId}' };`;
920
+ ${modDirection ? emitDirectionCheck(`app.getItemById('${itemId}')`, modDirection) : ''}${modTabular ? emitTabularCheck(`app.getItemById('${itemId}')`) : ''}${modStrokePos ? emitStrokePositionCheck(`app.getItemById('${itemId}')`, modStrokePos) : ''}
921
+ return { success: true, itemId: '${itemId}'${modFit ? ', textFit: __textFit' : ''}${modDirection ? ', direction: __direction' : ''}${modTabular ? ', tabularFigures: __tabular' : ''}${modStrokePos ? ', strokePosition: __strokePos' : ''}${affine ? `, affine: ${JSON.stringify({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : ''}${modLifetime ? ', lifetime: __lifetime' : ''}${modFont ? ', ...(__font ? { font: __font } : {})' : ''}${modWarning} };`;
275
922
  // Wrapped, because the body now RETURNS early on a miss rather than falling
276
- // through to an unconditional success.
923
+ // through to an unconditional success. Async only when a font must load
924
+ // before modifyItem re-measures the text.
925
+ if (modFont) {
926
+ return `(async function() {${emitEnsureFont(properties.fontFamily)}\n${code.trim()}\n})();`;
927
+ }
277
928
  return `(function() {\n${code.trim()}\n})();`;
278
929
  }
279
930
  /**
@@ -310,18 +961,143 @@ function generateDeleteItemCode(itemId) {
310
961
  * Template for adding relations
311
962
  */
312
963
  function generateAddRelationCode(sourceId, targetId, relationType, params) {
964
+ // AN UNKNOWN ENDPOINT IS REFUSED, NOT STORED (round 9 GG, 1.74). The engine
965
+ // stores a relation to an id it cannot resolve, returns true, and says "it
966
+ // will be skipped every frame and nothing will move" only in a warning this
967
+ // tool never sees. Its own test for that warning — _isKnownRelationEndpoint:
968
+ // an item, the camera, a cursor target — is asked first here, after the same
969
+ // '#id' forgiveness the engine applies.
313
970
  return `
314
971
  // Add ${relationType} relation: ${sourceId} -> ${targetId}
315
- const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
316
- if (!success) {
317
- throw new Error('Failed to add relation');
318
- }
319
- app.historyManager.saveState();
320
-
321
- // Return success
322
- ({ success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' });
972
+ (function() {
973
+ const norm = function(id) { return typeof app._resolveRelationEndpoint === 'function' ? app._resolveRelationEndpoint(id) : id; };
974
+ if (typeof app._isKnownRelationEndpoint === 'function') {
975
+ const bad = [${JSON.stringify(sourceId)}, ${JSON.stringify(targetId)}].filter(function(id) { return !app._isKnownRelationEndpoint(norm(id)); });
976
+ if (bad.length) {
977
+ return { success: false, relationType: '${relationType}', error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')
978
+ + ' ' + (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).' };
979
+ }
980
+ }
981
+ const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
982
+ if (!success) {
983
+ return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}.' };
984
+ }
985
+ app.historyManager.saveState();
986
+ return { success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' };
987
+ })();
323
988
  `.trim();
324
989
  }
990
+ /**
991
+ * moves_along_path, from the parameters a caller actually writes.
992
+ *
993
+ * The rule is a SELF relation reading params.path as a point array, with speed
994
+ * (1 = 150 px/s), closed, phase, easing, signal, equation. The natural call —
995
+ * targetId: <a path item>, params {pathId, duration, delay} — named none of
996
+ * those, so the relation had no path, returned success:true, and the whiteboard
997
+ * hand never moved (round 7 AA, 2.25). Here:
998
+ * - a path ITEM (targetId other than the source, or params.pathId) is
999
+ * sampled into points in the page, closed taken from the item;
1000
+ * - duration (seconds for one traversal) becomes speed;
1001
+ * - delay becomes the relation's window start (relation time is
1002
+ * window-relative, so the ride starts at `delay`);
1003
+ * - with neither points, a path item nor an equation, it is refused by name.
1004
+ */
1005
+ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1006
+ const { pathId, duration, delay, offset, ...params } = raw;
1007
+ const ref = (typeof pathId === 'string' && pathId) || (targetId && targetId !== sourceId ? targetId : null);
1008
+ const off = Array.isArray(offset) && offset.length >= 2 ? [Number(offset[0]) || 0, Number(offset[1]) || 0] : null;
1009
+ const hasDuration = typeof duration === 'number' && duration > 0;
1010
+ const hasDelay = typeof delay === 'number' && delay > 0;
1011
+ return `
1012
+ // moves_along_path: ${sourceId}${ref ? ` along ${ref}` : ''}
1013
+ (function() {
1014
+ const params = ${JSON.stringify(params)};
1015
+ const hasPoints = Array.isArray(params.path) && params.path.length >= 2;
1016
+ // A STUDIO THAT RIDES THE PATH ITSELF. From FxTool c16c9dd3 the rule
1017
+ // declares duration / delay / offset and, with a path item as its target and
1018
+ // no points, follows that path's LIVE geometry on the playhead. Sampling it
1019
+ // into static points here would shadow that, so on such a build everything
1020
+ // is passed straight through and the target is the route.
1021
+ const rule = app.relationRegistry && typeof app.relationRegistry.getRule === 'function'
1022
+ ? app.relationRegistry.getRule('moves_along_path') : null;
1023
+ const native = !!(rule && rule.params && rule.params.duration);
1024
+ if (native && !hasPoints && !params.equation ${ref ? '' : '&& false'}) {
1025
+ ${hasDuration ? `params.duration = ${duration};` : ''}
1026
+ ${hasDelay ? `params.delay = ${delay};` : ''}
1027
+ ${off ? `params.offset = ${JSON.stringify(off)};` : ''}
1028
+ const okN = app.addRelation(${JSON.stringify(sourceId)}, ${JSON.stringify(ref ?? sourceId)}, 'moves_along_path', params);
1029
+ if (!okN) return { success: false, error: 'the studio refused the moves_along_path relation.' };
1030
+ app.historyManager.saveState();
1031
+ return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'live-path', target: ${JSON.stringify(ref)}, params: params };
1032
+ }
1033
+ if (!hasPoints && !params.equation) {
1034
+ ${ref ? `const e = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(ref)});
1035
+ let p = e && e.item;
1036
+ if (p && p.className !== 'Path' && p.className !== 'CompoundPath' && typeof p.getItem === 'function') {
1037
+ p = p.getItem({ className: 'Path' });
1038
+ }
1039
+ if (!p || typeof p.getPointAt !== 'function' || !(p.length > 0)) {
1040
+ return { success: false, error: ${JSON.stringify(ref)} + ' is not a path with length — moves_along_path needs a path item, or params.path as points [[x, y], …].' };
1041
+ }
1042
+ const N = Math.min(240, Math.max(16, Math.ceil(p.length / 6)));
1043
+ params.path = Array.from({ length: N + 1 }, function(_, i) { const q = p.getPointAt(Math.min(p.length, p.length * i / N)); return [q.x, q.y]; });
1044
+ if (params.closed === undefined) params.closed = !!p.closed;` : `return { success: false, error: 'moves_along_path needs a path: pass targetId (or params.pathId) naming a path item, or params.path as points [[x, y], …], or params.equation. With none, the item would not move.' };`}
1045
+ }
1046
+ ${off ? `// offset, with the ENGINE's meaning (c16c9dd3): [dx, dy] is added to the
1047
+ // point on the path to place the item — so a hand whose pen tip sits
1048
+ // (-dx, -dy) from its centre keeps the tip on the stroke. The same value
1049
+ // therefore behaves the same on a build that reads offset natively.
1050
+ if (Array.isArray(params.path)) params.path = params.path.map(function(q) {
1051
+ const x = Array.isArray(q) ? q[0] : q.x, y = Array.isArray(q) ? q[1] : q.y;
1052
+ return [x + ${off[0]}, y + ${off[1]}];
1053
+ });` : ''}
1054
+ ${hasDuration ? `if (params.speed === undefined && Array.isArray(params.path)) {
1055
+ const pts = params.path.map(function(q) { return Array.isArray(q) ? { x: q[0], y: q[1] } : q; });
1056
+ const len = pts.slice(1).reduce(function(acc, q, i) { return acc + Math.hypot(q.x - pts[i].x, q.y - pts[i].y); }, 0);
1057
+ if (len > 0) params.speed = len / (150 * ${duration});
1058
+ if (params.closed === undefined) params.closed = false;
1059
+ }` : ''}
1060
+ ${hasDelay ? `if (!params.window) params.window = { start: ${delay} };` : ''}
1061
+ // Before its window opens the relation does not run, so the item stays where
1062
+ // it was left — the retest saw it parked at the path END. Put it at the start.
1063
+ if (Array.isArray(params.path) && params.path.length) {
1064
+ const src = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(sourceId)});
1065
+ const q0 = params.path[0];
1066
+ if (src && src.item && src.item.position) { src.item.position.x = Array.isArray(q0) ? q0[0] : q0.x; src.item.position.y = Array.isArray(q0) ? q0[1] : q0.y; }
1067
+ }
1068
+ const ok = app.addRelation(${JSON.stringify(sourceId)}, ${JSON.stringify(sourceId)}, 'moves_along_path', params);
1069
+ if (!ok) return { success: false, error: 'the studio refused the moves_along_path relation.' };
1070
+ app.historyManager.saveState();
1071
+ return { success: true, sourceId: ${JSON.stringify(sourceId)}, relationType: 'moves_along_path', route: 'sampled-points',
1072
+ points: Array.isArray(params.path) ? params.path.length : 0, speed: params.speed, closed: params.closed,
1073
+ ${ref ? `pathFrom: ${JSON.stringify(ref)},` : ''} window: params.window };
1074
+ })();`.trim();
1075
+ }
1076
+ /**
1077
+ * Parameter names a generator's docs used that the engine does not read.
1078
+ *
1079
+ * drawWindField was documented with `direction` and `speed`; the engine reads
1080
+ * windDirection and windSpeed, so the documented call drew wind blowing the
1081
+ * default way (round 8 BB, 5.56). The docs are corrected, and the published
1082
+ * spellings are mapped because callers followed them. An explicit engine name
1083
+ * always wins.
1084
+ */
1085
+ const GENERATOR_PARAM_ALIASES = {
1086
+ drawWindField: { direction: 'windDirection', speed: 'windSpeed' },
1087
+ };
1088
+ function aliasGeneratorParams(name, params) {
1089
+ const map = GENERATOR_PARAM_ALIASES[name];
1090
+ if (!map)
1091
+ return params;
1092
+ const out = { ...params };
1093
+ for (const [from, to] of Object.entries(map)) {
1094
+ if (out[from] !== undefined && out[to] === undefined) {
1095
+ out[to] = out[from];
1096
+ delete out[from];
1097
+ }
1098
+ }
1099
+ return out;
1100
+ }
325
1101
  /**
326
1102
  * Template for removing relations
327
1103
  */
@@ -471,12 +1247,63 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
471
1247
  opts.clipOutPoint = clipOutPoint;
472
1248
  if (timeUnits !== undefined)
473
1249
  opts.timeUnits = timeUnits;
1250
+ // A FADE ON AN AUDIO ITEM WAS ACCEPTED AND NEVER HEARD. addAnimation keyframes
1251
+ // the audio item's sentinel, and the mix reads one static audioGain per clip,
1252
+ // so {audioGain: 0→1} answered success, duration 15, and exported at a flat
1253
+ // level (measured). Refused by name, before anything is added, and only for
1254
+ // audio items — a `volume` key on a shape is some other thing.
1255
+ const levelKeys = [...new Set(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))]
1256
+ .filter((k) => k === 'audioGain' || k === 'volume' || k === 'gain');
1257
+ // UNLESS THE STUDIO ANIMATES IT. From FxTool fcec0194 keyed gain / volume
1258
+ // are real — fades and ducks bake into the export at the measured level —
1259
+ // and listAnimatableProperties lists them with appliesTo 'audio'. That list
1260
+ // is the feature check: a key it names for audio passes through, anything
1261
+ // else on an audio item is still refused (prod, until that build ships).
1262
+ const audioLevelGuard = levelKeys.length === 0 ? '' : `
1263
+ const __ae = app.itemRegistry && app.itemRegistry.get('${itemId}');
1264
+ let __audioKeyed = [];
1265
+ try {
1266
+ const __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null;
1267
+ __audioKeyed = ((__l && __l.properties) || []).filter(function(p) { return p && p.appliesTo === 'audio'; }).map(function(p) { return p.name; });
1268
+ } catch (_) { /* treat as unsupported */ }
1269
+ const __unsupported = ${JSON.stringify(levelKeys)}.filter(function(k) { return __audioKeyed.indexOf(k) === -1; });
1270
+ if (__ae && __ae.type === 'audio' && __unsupported.length) {
1271
+ return { success: false, itemId: '${itemId}', error: __unsupported.join(', ') + ' cannot be keyframed on an audio item in this studio: it mixes each audio clip at one fixed level, so a fade would be accepted and not heard. Nothing was added. '
1272
+ + (__audioKeyed.length ? 'Keyable on audio here: ' + __audioKeyed.join(', ') + '. ' : '')
1273
+ + 'Set a static level with pinepaper_modify_item {audioGain: 0..1}.' };
1274
+ }`;
1275
+ // Easing is read from the DESTINATION keyframe, so easing on the first one
1276
+ // never applies — a Ken Burns eased only there rendered linear. Said in the
1277
+ // result rather than silently moved: moving it would change what the other
1278
+ // keys the caller wrote mean.
1279
+ // A KEY THAT LEAVES A PROPERTY OUT (round 9 HH, engine 2.30): the engine
1280
+ // treats the gap as a break in that property's track rather than holding or
1281
+ // interpolating across it, silently. Named here — which keys lack which
1282
+ // properties — with the fix a caller can make now: give every key the full set.
1283
+ const propSets = keyframes.map((k) => Object.keys(k.properties ?? {}));
1284
+ const allProps = [...new Set(propSets.flat())];
1285
+ const gaps = keyframes
1286
+ .map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
1287
+ .filter((g) => g.missing.length);
1288
+ const gapNote = gaps.length && keyframes.length > 1
1289
+ ? `, propertyGaps: ${JSON.stringify(gaps.slice(0, 10))}, gapWarning: ${JSON.stringify(`${gaps.length} keyframe(s) leave out a property other keys animate (${[...new Set(gaps.flatMap((g) => g.missing))].join(', ')}). The engine can break that property's track at such a key; repeat the value on every key — hold it by repeating the previous value.`)}`
1290
+ : '';
1291
+ const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
1292
+ // loop:true repeats in the editor; an export of the measured build played
1293
+ // the keyframes ONCE and held (round 7, 2.24). Said where it is set, with
1294
+ // the two ways that do export, until the engine's export clock wraps them.
1295
+ const loopNote = loop
1296
+ ? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
1297
+ : '';
1298
+ const easingNote = first?.easing && first.easing !== 'linear'
1299
+ ? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
1300
+ : '';
474
1301
  return `
475
1302
  // Apply keyframe animation to ${itemId}
476
1303
  (function() {
477
- ${requireItem(itemId, 'the animation')}
1304
+ ${requireItem(itemId, 'the animation')}${audioLevelGuard}
478
1305
  app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
479
- return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''} };
1306
+ return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote} };
480
1307
  })();
481
1308
  `.trim();
482
1309
  }
@@ -514,17 +1341,41 @@ function generateImportMotionCaptureCode(bvh, mode, skeletonId, opts = {}) {
514
1341
  })()`;
515
1342
  }
516
1343
  function generateExecuteGeneratorCode(generatorName, params, region) {
517
- // FxTool signature: app.executeGenerator(name, params, options).
518
- // region lives in options (added in FxTool c81781c).
519
- const options = region ? { region } : {};
1344
+ const where = !region ? '' : region.width !== undefined
1345
+ ? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})`
1346
+ : ` in the shape of ${region.shape.itemId}`;
520
1347
  return `
521
- // Execute ${generatorName} generator${region ? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})` : ''}
522
- (async function() {
523
- await app.executeGenerator('${generatorName}', ${JSON.stringify(params, null, 2)}, ${JSON.stringify(options)});
524
- return { success: true, generator: '${generatorName}'${region ? ', region: ' + JSON.stringify(region) : ''} };
1348
+ // Execute ${generatorName} generator${where}
1349
+ (async function() {${emitGeneratorRun(generatorName, JSON.stringify(params, null, 2), region)}
525
1350
  })();
526
1351
  `.trim();
527
1352
  }
1353
+ /**
1354
+ * Run a generator, optionally into a region, and return its result. Shared by
1355
+ * execute_generator and the batch op. FxTool signature:
1356
+ * app.executeGenerator(name, params, options), region in options (c81781c).
1357
+ *
1358
+ * A SHAPED REGION (c68524a2) falls back to a plain rectangle with only a
1359
+ * console.warn — for a missing item, or one that is not a path — and an older
1360
+ * engine drops the shape without a word (and, given only {itemId}, has no box
1361
+ * at all and draws full-canvas). So the item is checked first and refused by
1362
+ * name, and the result says whether this studio clips to shapes.
1363
+ */
1364
+ function emitGeneratorRun(generatorName, paramsJson, region) {
1365
+ const options = JSON.stringify(region ? { region } : {});
1366
+ const itemId = region && typeof region.shape === 'object' ? region.shape.itemId : null;
1367
+ const shaped = !!region && region.shape !== undefined && region.shape !== 'rect';
1368
+ return `${itemId ? `
1369
+ {
1370
+ const __se = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(itemId)});
1371
+ const __si = __se && __se.item;
1372
+ if (!__si) return { success: false, error: ${JSON.stringify(`region.shape names no item "${itemId}" — nothing was drawn. List items with pinepaper_get_items.`)} };
1373
+ if (__si.className !== 'Path' && __si.className !== 'CompoundPath') return { success: false, error: ${JSON.stringify(`region.shape item "${itemId}" is a `)} + __si.className + ', and a clip needs a path (a shape, not text, a group or an image) — nothing was drawn.' };
1374
+ }` : ''}
1375
+ await app.executeGenerator('${generatorName}', ${paramsJson}, ${options});${shaped ? `
1376
+ const __shapes = !!(app.generatorRegistry && typeof app.generatorRegistry._regionClipPath === 'function');` : ''}
1377
+ return { success: true, generator: '${generatorName}'${region ? `, region: ${JSON.stringify(region)}` : ''}${shaped ? `, ...(__shapes ? {} : { regionShapeApplied: false, warning: ${JSON.stringify(`this studio does not clip generator regions to a shape, so ${typeof region.shape === 'object' ? 'the region had no box and the generator drew across the whole canvas' : `the ${region.shape} was drawn as a rectangle`}. It needs an engine with region shapes.`)} })` : ''} };`;
1378
+ }
528
1379
  /**
529
1380
  * Template for applying effects
530
1381
  */
@@ -829,6 +1680,21 @@ const size = app.getCanvasSize();
829
1680
  ({ width: size.width, height: size.height });
830
1681
  `.trim();
831
1682
  }
1683
+ /**
1684
+ * Uploaded audio / video, removed through the media pipeline.
1685
+ *
1686
+ * clearCanvas empties the item groups and registries, but an uploaded clip
1687
+ * also lives in the audio / video layers — so a video from the previous job
1688
+ * survived agent_start_job {clearCanvas:true} and played in the next scene
1689
+ * (round 8 retest, 1.66). removeMedia is the full removal (registry entry,
1690
+ * stored blob, layer). Run before clearCanvas; forEach, not a generated loop.
1691
+ */
1692
+ const MEDIA_PURGE = `
1693
+ let __mediaRemoved = 0;
1694
+ const __PA = (typeof window !== 'undefined') && window.PinePaperAgent;
1695
+ if (__PA && typeof __PA.listMedia === 'function' && typeof __PA.removeMedia === 'function') {
1696
+ __PA.listMedia().forEach(function(m) { try { if (__PA.removeMedia(m.id)) __mediaRemoved++; } catch (_) { /* keep clearing */ } });
1697
+ }`;
832
1698
  /**
833
1699
  * Template for clearing all items from canvas
834
1700
  */
@@ -841,6 +1707,7 @@ function generateClearCanvasCode() {
841
1707
  // entries don't expose those — getItemById wants the registry id), so it
842
1708
  // removed 0 items, and it called the non-existent app.clearAllRelations.
843
1709
  const before = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
1710
+ ${MEDIA_PURGE}
844
1711
 
845
1712
  if (typeof app.clearCanvas === 'function') {
846
1713
  app.clearCanvas();
@@ -856,7 +1723,7 @@ if (app.historyManager) app.historyManager.saveState();
856
1723
 
857
1724
  const remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;
858
1725
 
859
- ({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining });
1726
+ ({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });
860
1727
  `.trim();
861
1728
  }
862
1729
  /**
@@ -1034,64 +1901,100 @@ export function planKeyframeMerges(operations) {
1034
1901
  return plan;
1035
1902
  }
1036
1903
  /**
1037
- * Template for batch create - creates multiple items with single history save
1904
+ * A create / modify snippet as the body of a function that RETURNS its value.
1905
+ * Such a snippet's value is its last top-level (-led statement; the
1906
+ * font-loading form is an async IIFE, which is awaited instead.
1907
+ */
1908
+ function asReturningBody(snippet) {
1909
+ const single = snippet.trim();
1910
+ if (single.startsWith('(async function()'))
1911
+ return `return await ${single.replace(/;\s*$/, '')};`;
1912
+ const lines = single.split('\n');
1913
+ let at = -1;
1914
+ lines.forEach((l, i) => { if (l.startsWith('('))
1915
+ at = i; });
1916
+ if (at >= 0)
1917
+ lines[at] = 'return ' + lines[at];
1918
+ return lines.join('\n');
1919
+ }
1920
+ /**
1921
+ * Template for batch create — create_item for each item, one history save.
1922
+ *
1923
+ * This called app.batchCreate, a third create path, and dropped fontWeight
1924
+ * (and every other create_item fix) with no ignoredProperties (round 9 II,
1925
+ * 1.79) — the same drift f1d0b05 removed from agent_batch_execute. Each item
1926
+ * now runs create_item's own emitter with its per-item history save taken
1927
+ * out, and the batch saves once at the end, which is what batchCreate was
1928
+ * for. An item that fails is reported by index; the rest are still created.
1038
1929
  */
1039
1930
  function generateBatchCreateCode(items) {
1040
- const itemsJson = JSON.stringify(items, null, 2);
1931
+ const blocks = items.map((it, i) => {
1932
+ const { x, y, position, ...props } = (it.params || {});
1933
+ const p = Array.isArray(position) ? { x: Number(position[0]), y: Number(position[1]) }
1934
+ : (position && typeof position === 'object') ? { x: Number(position.x), y: Number(position.y) }
1935
+ : { x: typeof x === 'number' ? x : 400, y: typeof y === 'number' ? y : 300 };
1936
+ const given = position !== undefined || typeof x === 'number' || typeof y === 'number';
1937
+ const body = asReturningBody(generateCreateItemCode(it.type, p, props, undefined, given))
1938
+ .replace(/app\.historyManager\.saveState\(\);/g, '');
1939
+ return `
1940
+ try {
1941
+ const r${i} = await (async function() {
1942
+ ${body}
1943
+ })();
1944
+ if (r${i} && r${i}.success === false) errors.push({ index: ${i}, error: r${i}.error });
1945
+ else results.push(Object.assign({ index: ${i} }, r${i}));
1946
+ } catch (e) { errors.push({ index: ${i}, error: (e && e.message) || String(e) }); }`;
1947
+ });
1041
1948
  return `
1042
- // Batch create ${items.length} items
1043
- const itemSpecs = ${itemsJson};
1044
- const createdItems = app.batchCreate(itemSpecs);
1045
-
1046
- // Return created item IDs and info
1047
- const results = createdItems.map(item => ({
1048
- itemId: item.data?.registryId || item.id,
1049
- type: item.className || 'unknown',
1050
- position: item.position ? { x: item.position.x, y: item.position.y } : null
1051
- }));
1052
-
1053
- ({ success: true, items: results, count: results.length });
1054
- `.trim();
1949
+ // Batch create ${items.length} items — create_item for each, one history save
1950
+ (async function() {
1951
+ const results = [];
1952
+ const errors = [];
1953
+ ${blocks.join('\n')}
1954
+ if (app.historyManager) app.historyManager.saveState();
1955
+ return { success: errors.length === 0, items: results, count: results.length, errors: errors };
1956
+ })();`.trim();
1055
1957
  }
1056
1958
  /**
1057
- * Template for batch modify - modifies multiple items with single history save
1959
+ * Template for batch modify — modify_item for each item, one history save.
1960
+ *
1961
+ * This called app.batchModify, which applies raw params and so missed every
1962
+ * modify_item fix (text weight / leading, font loading, audio level, no-fill,
1963
+ * lifetimes, smoothing, the unread-property report) — the drift f1d0b05 and
1964
+ * 8f9b075 removed from the other batch paths. Each item now runs modify_item's
1965
+ * own emitter with its history save removed; the batch saves once. The
1966
+ * result keeps the old shape (count, requested, skipped with reasons, and an
1967
+ * error naming the skipped when any were).
1058
1968
  */
1059
1969
  function generateBatchModifyCode(modifications) {
1060
- const modsJson = JSON.stringify(modifications, null, 2);
1970
+ const blocks = modifications.map((m, i) => {
1971
+ const snippet = generateModifyItemCode(m.itemId, (m.params || {})).trim()
1972
+ .replace(/app\.historyManager\.saveState\(\);/g, '')
1973
+ .replace(/;\s*$/, '');
1974
+ return `
1975
+ try {
1976
+ const r${i} = await ${snippet};
1977
+ if (r${i} && r${i}.success === false) skipped.push({ itemId: ${JSON.stringify(m.itemId)}, reason: r${i}.error || 'not modified' });
1978
+ else { count++; if (r${i} && r${i}.ignoredProperties) ignored.push({ itemId: ${JSON.stringify(m.itemId)}, ignoredProperties: r${i}.ignoredProperties }); }
1979
+ } catch (e) { skipped.push({ itemId: ${JSON.stringify(m.itemId)}, reason: (e && e.message) || String(e) }); }`;
1980
+ });
1061
1981
  return `
1062
- // Batch modify ${modifications.length} items
1063
- const modifications = ${modsJson};
1064
- // TWO BUGS, ONE CALL, AND BOTH FAILED LOUDLY IN THE WRONG PLACE.
1065
- //
1066
- // 1. The engine takes { item, params } with a LIVE ITEM, or { itemId, params }
1067
- // with a registry id. This put the id STRING into the item field, which is
1068
- // truthy — so the engine's own id lookup never ran, the string was treated
1069
- // as an item, and Object.assign got a string: "Cannot convert undefined or
1070
- // null to object", from a call that named the id correctly.
1071
- // 2. batchModify returns a COUNT, not an array. Calling .map on a number
1072
- // is the second reported error, and it fires even when the modify worked.
1073
- const results = app.batchModify(modifications.map(mod => ({
1074
- itemId: mod.itemId,
1075
- params: mod.params
1076
- })));
1077
-
1078
- // The engine records what it could not do rather than throwing — read it,
1079
- // instead of reporting a clean success over a half-applied batch.
1080
- //
1081
- // Read defensively (|| []): the property arrived with the engine-side
1082
- // batchModify fix, so an older studio reports a count and no skip list rather
1083
- // than failing. The batch still works; only the explanation is thinner.
1084
- const skipped = app.lastBatchModifySkipped || [];
1085
- const count = typeof results === 'number' ? results : (Array.isArray(results) ? results.length : 0);
1086
-
1087
- ({
1088
- success: skipped.length === 0 && count === modifications.length,
1089
- count,
1090
- requested: modifications.length,
1091
- skipped: skipped.map(s => ({ itemId: s.itemId, reason: s.reason })),
1092
- ...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ' + modifications.length + ' items; skipped: ' + skipped.map(s => s.itemId + ' (' + s.reason + ')').join(', ') } : {}),
1093
- });
1094
- `.trim();
1982
+ // Batch modify ${modifications.length} items — modify_item for each, one history save
1983
+ (async function() {
1984
+ let count = 0;
1985
+ const skipped = [];
1986
+ const ignored = [];
1987
+ ${blocks.join('\n')}
1988
+ if (app.historyManager) app.historyManager.saveState();
1989
+ return {
1990
+ success: skipped.length === 0 && count === ${modifications.length},
1991
+ count: count,
1992
+ requested: ${modifications.length},
1993
+ skipped: skipped,
1994
+ ...(ignored.length ? { ignoredProperties: ignored } : {}),
1995
+ ...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ${modifications.length} items; skipped: ' + skipped.map(function(s) { return s.itemId + ' (' + s.reason + ')'; }).join(', ') } : {}),
1996
+ };
1997
+ })();`.trim();
1095
1998
  }
1096
1999
  /**
1097
2000
  * Template for create grid - convenience method for creating line grids
@@ -1148,6 +2051,8 @@ const PLATFORM_TO_CANVAS_PRESET = {
1148
2051
  web: 'hd-720p', // 1280x720
1149
2052
  'print-a4': 'a4-portrait',
1150
2053
  'print-letter': 'letter-portrait',
2054
+ 'print-a4-landscape': 'a4-landscape',
2055
+ 'print-letter-landscape': 'letter-landscape',
1151
2056
  };
1152
2057
  /** The engine key for a platform, falling back to the value itself. */
1153
2058
  export function canvasPresetFor(platform) {
@@ -1293,6 +2198,7 @@ export class PinePaperCodeGenerator {
1293
2198
  */
1294
2199
  generateCreateItem(input) {
1295
2200
  const validated = CreateItemInputSchema.parse(input);
2201
+ const positionGiven = input.position !== undefined;
1296
2202
  const properties = { ...validated.properties };
1297
2203
  if (validated.animationType !== undefined)
1298
2204
  properties.animationType = validated.animationType;
@@ -1304,14 +2210,16 @@ export class PinePaperCodeGenerator {
1304
2210
  properties.animationDelay = validated.animationDelay;
1305
2211
  if (validated.keyframes !== undefined)
1306
2212
  properties.keyframes = validated.keyframes;
1307
- return generateCreateItemCode(validated.itemType, validated.position, properties);
2213
+ if (validated.anchor !== undefined && properties.anchor === undefined && properties.origin === undefined)
2214
+ properties.anchor = validated.anchor;
2215
+ return generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven);
1308
2216
  }
1309
2217
  /**
1310
2218
  * Generate code for modifying an item
1311
2219
  */
1312
2220
  generateModifyItem(input) {
1313
2221
  const validated = ModifyItemInputSchema.parse(input);
1314
- return generateModifyItemCode(validated.itemId, validated.properties);
2222
+ return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
1315
2223
  }
1316
2224
  /**
1317
2225
  * Generate code for deleting an item
@@ -1346,6 +2254,9 @@ export class PinePaperCodeGenerator {
1346
2254
  return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
1347
2255
  })();`.trim();
1348
2256
  }
2257
+ if (validated.relationType === 'moves_along_path') {
2258
+ return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
2259
+ }
1349
2260
  return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
1350
2261
  }
1351
2262
  /**
@@ -1400,7 +2311,7 @@ export class PinePaperCodeGenerator {
1400
2311
  */
1401
2312
  generateExecuteGenerator(input) {
1402
2313
  const validated = ExecuteGeneratorInputSchema.parse(input);
1403
- return generateExecuteGeneratorCode(validated.generatorName, validated.params, validated.region);
2314
+ return generateExecuteGeneratorCode(validated.generatorName, aliasGeneratorParams(validated.generatorName, (validated.params ?? {})), validated.region);
1404
2315
  }
1405
2316
  /**
1406
2317
  * Generate code for applying an effect
@@ -1932,72 +2843,41 @@ if (typeof _gen === 'function') {
1932
2843
  for (const item of items) {
1933
2844
  const { name, itemType, position = { x: 400, y: 300 }, properties = {} } = item;
1934
2845
  // Build properties string
1935
- const propsEntries = Object.entries(properties)
1936
- .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
1937
- .join(', ');
1938
- const propsStr = propsEntries ? `, ${propsEntries}` : '';
2846
+ // CREATE_SCENE HAND-BUILT ITS OWN SHAPES, AND MOST OF THEM WERE WRONG.
2847
+ //
2848
+ // This emitted a per-type switch of raw Paper constructors — a parallel
2849
+ // implementation of app.create covering four cases, whose DEFAULT branch
2850
+ // made a 30px circle. So ellipse, triangle, polygon, path, line and arc —
2851
+ // six of the ten types this schema declares — silently rendered as a
2852
+ // small blue circle, and every property the switch did not name was
2853
+ // dropped. A pilot reported ellipse width/height being ignored; the same
2854
+ // values work through batch because batch calls app.create.
2855
+ //
2856
+ // Routed through app.create now, which is the only thing that knows all
2857
+ // 28 item types, the shape registry's own parameters, anchor, label,
2858
+ // rotation and the registry id. Deleting the copy is the fix: a second
2859
+ // implementation of "make a shape" could only ever drift from the first.
2860
+ const sceneParams = {
2861
+ x: position.x,
2862
+ y: position.y,
2863
+ ...properties,
2864
+ };
1939
2865
  codeParts.push(`
1940
2866
  // Create item: ${name}
1941
2867
  (function() {
1942
- const itemParams = {
1943
- type: '${itemType}',
1944
- position: { x: ${position.x}, y: ${position.y} }${propsStr}
1945
- };
1946
-
1947
- let item;
1948
- switch ('${itemType}') {
1949
- case 'circle':
1950
- item = new paper.Path.Circle({
1951
- center: [${position.x}, ${position.y}],
1952
- radius: ${properties.radius || 50},
1953
- fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
1954
- parent: app.textItemGroup
1955
- });
1956
- break;
1957
- case 'rectangle':
1958
- item = new paper.Path.Rectangle({
1959
- point: [${position.x - (properties.width || 100) / 2}, ${position.y - (properties.height || 60) / 2}],
1960
- size: [${properties.width || 100}, ${properties.height || 60}],
1961
- fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
1962
- parent: app.textItemGroup
1963
- });
1964
- break;
1965
- case 'star':
1966
- item = new paper.Path.Star({
1967
- center: [${position.x}, ${position.y}],
1968
- points: ${properties.points || 5},
1969
- radius1: ${properties.radius1 || 40},
1970
- radius2: ${properties.radius2 || 20},
1971
- fillColor: '${properties.color || properties.fillColor || '#fbbf24'}',
1972
- parent: app.textItemGroup
1973
- });
1974
- break;
1975
- case 'text':
1976
- item = new paper.PointText({
1977
- point: [${position.x}, ${position.y}],
1978
- content: '${properties.content || 'Text'}',
1979
- fontSize: ${properties.fontSize || 24},
1980
- fillColor: '${properties.color || properties.fillColor || '#ffffff'}',
1981
- fontFamily: '${properties.fontFamily || 'Inter'}',
1982
- justification: 'center',
1983
- parent: app.textItemGroup
1984
- });
1985
- break;
1986
- default:
1987
- item = new paper.Path.Circle({
1988
- center: [${position.x}, ${position.y}],
1989
- radius: 30,
1990
- fillColor: '${properties.color || properties.fillColor || '#3b82f6'}',
1991
- parent: app.textItemGroup
1992
- });
2868
+ const item = app.create('${itemType}', ${JSON.stringify(sceneParams)});
2869
+ if (!item) {
2870
+ results.items.push({ name: '${name}', error: 'app.create returned nothing for type ${itemType} — check the type and its properties.' });
2871
+ return;
1993
2872
  }
1994
-
1995
- const itemId = app.registerItem(item, '${name}', { source: 'mcp-scene' });
2873
+ // create() registers and mints the id; only fall back for an older studio.
2874
+ const itemId = (item.data && item.data.id)
2875
+ ? item.data.id
2876
+ : app.registerItem(item, '${itemType}', { source: 'mcp-scene' });
1996
2877
  if (item.bringToFront) item.bringToFront();
1997
2878
  nameToId['${name}'] = itemId;
1998
2879
  results.items.push({ name: '${name}', itemId, type: '${itemType}' });
1999
- })();
2000
- `);
2880
+ })();`);
2001
2881
  }
2002
2882
  // Establish relations
2003
2883
  for (const relation of relations) {
@@ -2018,8 +2898,12 @@ if (typeof _gen === 'function') {
2018
2898
  }
2019
2899
  // Apply animations
2020
2900
  for (const animation of animations) {
2021
- const { target, type, speed = 1.0, params = {} } = animation;
2022
- const paramsStr = JSON.stringify({ ...params, speed });
2901
+ const { target, type, speed = 1.0, startTime, params = {} } = animation;
2902
+ const paramsStr = JSON.stringify({
2903
+ ...params,
2904
+ speed,
2905
+ ...(startTime !== undefined ? { animationDelay: startTime } : {}),
2906
+ });
2023
2907
  codeParts.push(`
2024
2908
  // Apply animation: ${type} to ${target}
2025
2909
  (function() {
@@ -2028,8 +2912,13 @@ if (typeof _gen === 'function') {
2028
2912
  const targetItem = app.itemRegistry.get(targetId);
2029
2913
  if (targetItem) {
2030
2914
  const params = ${paramsStr};
2915
+ // startTime becomes animationDelay, which is what the engine reads
2916
+ // (PinePaper.js stores it on item.data.animationDelay). These are ambient
2917
+ // loops with no end, so there is no duration to map and none is invented
2918
+ // — staggering the STARTS is what makes a scene read as choreographed
2919
+ // rather than everything moving at once from frame zero.
2031
2920
  app.animate(targetItem, { animationType: '${type}', ...params });
2032
- results.animations.push({ target: '${target}', type: '${type}' });
2921
+ results.animations.push({ target: '${target}', type: '${type}'${startTime !== undefined ? `, startTime: ${startTime}` : ''} });
2033
2922
  }
2034
2923
  }
2035
2924
  })();
@@ -2400,7 +3289,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2400
3289
  `;
2401
3290
  if (shouldClear) {
2402
3291
  code += `
2403
- // Clear canvas
3292
+ // Clear canvas — uploaded media first (it lives outside the item groups)${MEDIA_PURGE}
2404
3293
  if (app.clearCanvas) {
2405
3294
  app.clearCanvas();
2406
3295
  } else {
@@ -2463,7 +3352,9 @@ throw new Error('Unknown diagram mode action: ${action}');
2463
3352
  */
2464
3353
  generateAgentEndJob(input) {
2465
3354
  const validated = AgentEndJobInputSchema.parse(input);
2466
- const { takeScreenshot, analyzeContent } = validated;
3355
+ const { analyzeContent } = validated;
3356
+ // includeScreenshot is the published name; takeScreenshot the older one.
3357
+ const takeScreenshot = validated.includeScreenshot ?? validated.takeScreenshot;
2467
3358
  const shouldAnalyze = analyzeContent !== false;
2468
3359
  let code = `
2469
3360
  // End agent job
@@ -2761,21 +3652,22 @@ throw new Error('Unknown diagram mode action: ${action}');
2761
3652
  generateBatchOperationCode(op, index, mergePlan) {
2762
3653
  switch (op.type) {
2763
3654
  case 'create': {
3655
+ // THE SAME CREATE AS create_item, NOT A SECOND ONE.
3656
+ //
3657
+ // This case was its own app.create call, and every create_item fix since
3658
+ // — fontWeight / leading, font loading, countdown content, no-fill,
3659
+ // dashes, lifetimes, the unread-property report — passed it by: a batch
3660
+ // read fontWeight 700 back as normal and a countdown stayed at 00:10
3661
+ // for a 12 s MP4 (round 8 DD, 1.67). It now runs generateCreateItemCode,
3662
+ // the exact emitter create_item uses, adapted to return its result
3663
+ // inside this op's function.
2764
3664
  const pos = op.position || { x: 400, y: 300 };
2765
3665
  const createProps = withRadiusAxes((op.properties || {}));
2766
- const props = JSON.stringify(createProps);
3666
+ const returning = asReturningBody(generateCreateItemCode(op.itemType, pos, { ...(op.properties || {}) }, undefined, op.position !== undefined));
2767
3667
  let createCode = `
2768
- const item = app.create('${op.itemType}', { position: { x: ${pos.x}, y: ${pos.y} }, ...${props} });
2769
- const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item, '${op.itemType}', { source: 'mcp-batch' });`;
2770
- // app.create() does not read blendMode/opacity from its params, but the
2771
- // underlying Paper.js item supports them directly — apply post-create so
2772
- // callers can pass them inline in `properties` and have them take effect.
2773
- if (createProps.blendMode !== undefined) {
2774
- createCode += `\nif ('blendMode' in item) item.blendMode = ${JSON.stringify(createProps.blendMode)};`;
2775
- }
2776
- if (createProps.opacity !== undefined) {
2777
- createCode += `\nif ('opacity' in item) item.opacity = ${JSON.stringify(createProps.opacity)};`;
2778
- }
3668
+ const __created = await (async function() {
3669
+ ${returning}
3670
+ })();`;
2779
3671
  // Coordinate-built items (path from segments/pathData, line/arc from
2780
3672
  // from/through/to) derive their geometry from those coordinates and
2781
3673
  // IGNORE params.position — so `create` at a point silently produced an
@@ -2787,30 +3679,35 @@ const itemId = item.data && item.data.id ? item.data.id : app.registerItem(item,
2787
3679
  createCode += `
2788
3680
  // create() builds this item type from its own coordinates and ignores
2789
3681
  // params.position — apply the caller's explicit position after the fact.
2790
- if (item.position) item.position = new paper.Point(${pos.x}, ${pos.y});`;
3682
+ const __it = __created && __created.itemId ? app.getItemById(__created.itemId) : null;
3683
+ if (__it && __it.position) __it.position = new paper.Point(${pos.x}, ${pos.y});`;
2791
3684
  }
2792
3685
  createCode += `
2793
- // Ensure item is visible above backgrounds/generators
2794
- if (item.bringToFront) item.bringToFront();
2795
- return { itemId };
3686
+ return __created;
2796
3687
  `;
2797
3688
  return createCode;
2798
3689
  }
2799
- case 'modify':
3690
+ case 'modify': {
2800
3691
  const itemRef = op.itemId?.startsWith('$')
2801
3692
  ? `itemIds[${op.itemId.substring(1)}]`
2802
3693
  : `'${op.itemId}'`;
2803
- const modifyProps = JSON.stringify(op.properties || {});
2804
- return `
2805
- const targetId = ${itemRef};
2806
- // modifyItem(id, changes), not select() + modify(). modify() edits the
2807
- // SELECTION, so an id that does not resolve left the PREVIOUS selection to be
2808
- // edited instead — and this then reported modified:true either way.
2809
- if (app.modifyItem(targetId, ${modifyProps}) === false) {
2810
- return { success: false, itemId: targetId, error: 'no item ' + targetId + ' — nothing was modified' };
2811
- }
2812
- return { itemId: targetId, modified: true };
3694
+ // modify_item's own emitter, as create is create_item's (see above):
3695
+ // the batch's modifyItem call missed text styles, font loading, audio
3696
+ // level, no-fill, lifetimes, smoothing and the unread-property report.
3697
+ // The emitter embeds the id as a literal; a batch ref ($N) is only known
3698
+ // at run time, so it is generated with a placeholder and the runtime
3699
+ // variable put in its place.
3700
+ const single = generateModifyItemCode('__MID__', (op.properties || {})).trim()
3701
+ .replace(/'__MID__'/g, '__mid')
3702
+ .replace(/__MID__/g, "' + __mid + '")
3703
+ .replace(/;\s*$/, '');
3704
+ return `
3705
+ const __mid = ${itemRef};
3706
+ const __r = await ${single};
3707
+ if (__r && __r.success !== false) __r.modified = true;
3708
+ return __r;
2813
3709
  `;
3710
+ }
2814
3711
  case 'animate':
2815
3712
  const animItemRef = op.itemId?.startsWith('$')
2816
3713
  ? `itemIds[${op.itemId.substring(1)}]`
@@ -2872,11 +3769,8 @@ return { success: true, backgroundColor: '${bgColor}' };
2872
3769
  `;
2873
3770
  case 'execute_generator':
2874
3771
  const genName = op.generatorName || 'drawSunburst';
2875
- const genParams = JSON.stringify(op.generatorParams || {});
2876
- const genOptions = JSON.stringify(op.generatorRegion ? { region: op.generatorRegion } : {});
2877
- return `
2878
- await app.executeGenerator('${genName}', ${genParams}, ${genOptions});
2879
- return { success: true, generator: '${genName}'${op.generatorRegion ? ', region: ' + JSON.stringify(op.generatorRegion) : ''} };
3772
+ const genParams = JSON.stringify(aliasGeneratorParams(genName, (op.generatorParams || {})));
3773
+ return `${emitGeneratorRun(genName, genParams, op.generatorRegion)}
2880
3774
  `;
2881
3775
  case 'set_canvas_size': {
2882
3776
  const w = op.width || 1080;
@@ -3051,7 +3945,32 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3051
3945
  }
3052
3946
  generateAgentExport(input) {
3053
3947
  const validated = AgentExportInputSchema.parse(input);
3054
- const { platform, format, quality, framing, duration, estimateOnly, scale, fps } = validated;
3948
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, alphaQuantizer, ad: adOpts } = validated;
3949
+ // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
3950
+ const videoEncodeOpts = {};
3951
+ if (broadcast)
3952
+ videoEncodeOpts.broadcast = true;
3953
+ // Headroom defaults to 12 with broadcast (retest of caa9560): on a
3954
+ // hard-edged white-on-black scene it measured 17-233 with nothing out of
3955
+ // range, where 0 left 0.34% out and a post-hoc clip made it worse. 0 is
3956
+ // still available by asking for it.
3957
+ const headroomDefaulted = !!broadcast && broadcastHeadroom === undefined;
3958
+ if (broadcast)
3959
+ videoEncodeOpts.broadcastHeadroom = broadcastHeadroom ?? 12;
3960
+ if (bitrate !== undefined)
3961
+ videoEncodeOpts.bitrate = bitrate;
3962
+ if (minBitrate !== undefined)
3963
+ videoEncodeOpts.minBitrate = minBitrate;
3964
+ if (bitrateMode !== undefined)
3965
+ videoEncodeOpts.bitrateMode = bitrateMode;
3966
+ // A transparent WebM (FxTool 1833b397) rides the store route too.
3967
+ const webmAlpha = format === 'webm' && transparent === true;
3968
+ if (webmAlpha) {
3969
+ videoEncodeOpts.transparent = true;
3970
+ if (alphaQuantizer !== undefined)
3971
+ videoEncodeOpts.alphaQuantizer = alphaQuantizer;
3972
+ }
3973
+ const encodeOptsJs = Object.keys(videoEncodeOpts).length ? `, ...${JSON.stringify(videoEncodeOpts)}` : '';
3055
3974
  const qualityLevel = quality || 'standard';
3056
3975
  const videoDuration = duration ?? 5;
3057
3976
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -3072,6 +3991,8 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3072
3991
  'web': { width: 800, height: 600, staticFormat: 'svg', animatedFormat: 'svg' },
3073
3992
  'print-a4': { width: 2480, height: 3508, staticFormat: 'pdf', animatedFormat: 'pdf' },
3074
3993
  'print-letter': { width: 2550, height: 3300, staticFormat: 'pdf', animatedFormat: 'pdf' },
3994
+ 'print-a4-landscape': { width: 3508, height: 2480, staticFormat: 'pdf', animatedFormat: 'pdf' },
3995
+ 'print-letter-landscape': { width: 3300, height: 2550, staticFormat: 'pdf', animatedFormat: 'pdf' },
3075
3996
  };
3076
3997
  const preset = platformPresets[platform] || platformPresets['web'];
3077
3998
  // SCALE is the only honest size control this surface has.
@@ -3118,6 +4039,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3118
4039
  // for by name. Dimensions, framing, fps and bitrate are all absent rather
3119
4040
  // than passed and ignored.
3120
4041
  // @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio ensureHeavyModules
4042
+ // CAPTIONS AS A FILE (round 8 EE, 8.19). format 'srt' was "Invalid input",
4043
+ // yet caption text staged with bornAt / ttl already carries frame-exact
4044
+ // times on item.data — the cues are in the scene. Every text item with a
4045
+ // lifetime becomes one cue, in start order; a text item without one is
4046
+ // on screen for the whole piece and is not a caption, so it is left out
4047
+ // and counted.
4048
+ // scc (CEA-608, 8.37) takes the same cues; the server encodes them
4049
+ // (src/utils/scc.ts), where the byte work is testable.
4050
+ if (exportFormat === 'srt' || exportFormat === 'vtt' || exportFormat === 'scc') {
4051
+ const fmt = exportFormat;
4052
+ return `
4053
+ // Export: captions (${fmt}) from text items with a lifetime
4054
+ (function() {
4055
+ if (!app.itemRegistry || typeof app.itemRegistry.getAll !== 'function') {
4056
+ return { success: false, format: '${fmt}', error: 'this studio cannot list items (app.itemRegistry.getAll) — update PinePaper Studio.' };
4057
+ }
4058
+ const texts = app.itemRegistry.getAll().filter(function(e) {
4059
+ const it = e && e.item;
4060
+ return it && it.className === 'PointText' && typeof it.content === 'string' && it.content.trim() !== '';
4061
+ });
4062
+ const timed = texts.filter(function(e) { return e.item.data && typeof e.item.data.bornAt === 'number'; });
4063
+ if (!timed.length) {
4064
+ return { success: false, format: '${fmt}', error: 'no captions to export: no text item has a lifetime. Stage each caption with properties.bornAt / ttl (seconds) on create_item or modify_item, then export ${fmt}.', untimedText: texts.length };
4065
+ }
4066
+ const end = ${videoDuration};
4067
+ const ts = function(t, sep) {
4068
+ const ms = Math.max(0, Math.round(t * 1000));
4069
+ const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60, r = ms % 1000;
4070
+ const p = function(n, w) { return String(n).padStart(w, '0'); };
4071
+ return p(h, 2) + ':' + p(m, 2) + ':' + p(s, 2) + sep + p(r, 3);
4072
+ };
4073
+ const cues = timed.map(function(e) {
4074
+ const d = e.item.data;
4075
+ const start = d.bornAt;
4076
+ const stop = typeof d.ttl === 'number' ? start + d.ttl : end;
4077
+ return { start: start, stop: Math.max(start, stop), text: e.item.content.trim() };
4078
+ }).sort(function(a, b) { return a.start - b.start; });
4079
+ ${fmt === 'scc' ? ` return { success: true, format: 'scc', sccCues: cues, cues: cues.length, untimedText: texts.length - timed.length };
4080
+ ` : ''} const body = cues.map(function(c, i) {
4081
+ return ${fmt === 'srt' ? `(i + 1) + '\\n' + ts(c.start, ',') + ' --> ' + ts(c.stop, ',')` : `ts(c.start, '.') + ' --> ' + ts(c.stop, '.')`} + '\\n' + c.text;
4082
+ }).join('\\n\\n') + '\\n';
4083
+ const text = ${fmt === 'vtt' ? `'WEBVTT\\n\\n' + body` : 'body'};
4084
+ return { success: true, format: '${fmt}', data: text, mimeType: '${fmt === 'srt' ? 'application/x-subrip' : 'text/vtt'}', size: text.length, cues: cues.length, untimedText: texts.length - timed.length };
4085
+ })();`.trim();
4086
+ }
3121
4087
  if (exportFormat === 'wav') {
3122
4088
  const sr = validated.sampleRate ?? 48000;
3123
4089
  const bd = validated.bitDepth ?? 16;
@@ -3195,7 +4161,30 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3195
4161
  const __even = (n) => Math.max(2, Math.round((n * ${scale !== undefined ? scale : 1}) / 2) * 2);
3196
4162
  const dimensions = (__canvasDims && __canvasDims.width && __canvasDims.height)
3197
4163
  ? { width: __even(__canvasDims.width), height: __even(__canvasDims.height) }
3198
- : __presetDims;` : ` const dimensions = ${JSON.stringify(scaled)};`}
4164
+ : __presetDims;
4165
+ const __fit = null;` : ` // A PRESET IS A SIZE, NOT PERMISSION TO STRETCH.
4166
+ //
4167
+ // 'instagram' is 1080x1080, and a 4:5 (1080x1350) canvas exported under it
4168
+ // came out squashed to 0.8 vertically, text distorted, with nothing said —
4169
+ // 4:5 is itself a valid Instagram feed size. When the canvas aspect differs
4170
+ // from the preset's, the canvas aspect is kept and its SHORT edge takes the
4171
+ // preset's short edge: 4:5 on instagram is 1080x1350, a square canvas on
4172
+ // youtube is 1080x1080. The result says so in platformFit.
4173
+ const __presetBox = ${JSON.stringify(scaled)};
4174
+ const __cv = app.canvasSize || (app.view && app.view.viewSize) || null;
4175
+ let dimensions = __presetBox;
4176
+ let __fit = null;
4177
+ if (__cv && __cv.width > 0 && __cv.height > 0) {
4178
+ const ca = __cv.width / __cv.height, pa = __presetBox.width / __presetBox.height;
4179
+ if (Math.abs(ca / pa - 1) > 0.01) {
4180
+ const short = Math.min(__presetBox.width, __presetBox.height);
4181
+ const ev = (n) => Math.max(2, Math.round(n / 2) * 2);
4182
+ dimensions = ca >= 1 ? { width: ev(short * ca), height: ev(short) } : { width: ev(short), height: ev(short / ca) };
4183
+ __fit = { preset: __presetBox.width + 'x' + __presetBox.height, used: dimensions.width + 'x' + dimensions.height,
4184
+ canvas: __cv.width + 'x' + __cv.height,
4185
+ note: 'the canvas aspect differs from the ' + platform + ' preset, so the canvas aspect was kept rather than stretched. Resize the canvas to the preset to get exactly ' + __presetBox.width + 'x' + __presetBox.height + '.' };
4186
+ }
4187
+ }`}
3199
4188
 
3200
4189
  // WHAT THIS SCENE LOSES TO THIS FORMAT, asked at the moment you would care.
3201
4190
  //
@@ -3204,17 +4193,97 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3204
4193
  // silence. The engine derived it so that 117 tool descriptions would not
3205
4194
  // have to answer "does this survive export?" by hand and drift.
3206
4195
  //
3207
- // Reported ONLY when there is something to report. An empty list would read
3208
- // as "this format is lossless", and it does not mean that — it means this
3209
- // scene has nothing THIS format will lose. Omitting it entirely is the one
3210
- // shape that cannot be misread, and it keeps a clean export quiet.
4196
+ // THREE STATES, ALL DISTINGUISHABLE. This used to be reported only when
4197
+ // there was something to report, so that an empty list could not be misread
4198
+ // as "this format is lossless". That solved one misreading and created a
4199
+ // worse one: a caller could not tell "checked, nothing lost" from "never
4200
+ // checked", and a pilot reported fidelity as null in EVERY scene with no way
4201
+ // to know whether that was good news or a broken check.
4202
+ //
4203
+ // So it is always present: available:false when the studio cannot check,
4204
+ // warnings:[] plus a note when it checked and found nothing, and the
4205
+ // warnings themselves otherwise. The note carries the caveat that absence
4206
+ // was standing in for.
4207
+ //
4208
+ // STAND-INS ARE THIS TOOL'S OWN DOING, SO THIS TOOL REPORTS THEM. create_item
4209
+ // draws 'shader' and 'field' locally as a flat plate tagged data.renderAs; the
4210
+ // engine's check sees an ordinary rectangle and cannot know a surface was
4211
+ // meant. So a local export of a sea came out a flat blue slab with nothing
4212
+ // said. Reported whatever the engine check can or cannot do.
4213
+ function standIns() {
4214
+ try {
4215
+ // Probed with === : a studio without it degrades to "no stand-ins".
4216
+ const canList = app.itemRegistry && typeof app.itemRegistry.getAll === 'function';
4217
+ if (!canList) return [];
4218
+ const found = {};
4219
+ for (const entry of app.itemRegistry.getAll()) {
4220
+ const kind = entry && entry.item && entry.item.data && entry.item.data.renderAs;
4221
+ if (kind === 'shader' || kind === 'field') (found[kind] = found[kind] || []).push(entry.itemId);
4222
+ }
4223
+ return Object.keys(found).map(function(kind) {
4224
+ const ids = found[kind];
4225
+ return {
4226
+ code: 'render_time_surface_stand_in',
4227
+ message: ids.length + ' ' + kind + ' item' + (ids.length > 1 ? 's are' : ' is')
4228
+ + ' exported as the flat plate that stands in for ' + (ids.length > 1 ? 'them' : 'it')
4229
+ + ' on a local canvas. The surface itself is drawn only by a cloud render.',
4230
+ items: ids.slice(0, 6),
4231
+ };
4232
+ });
4233
+ } catch (e) { return []; }
4234
+ }
4235
+ // TRANSPARENCY THE FORMAT CANNOT CARRY. A scene with no background exported
4236
+ // to webm came out on BLACK while the engine's check said it lost nothing
4237
+ // (round 7, 8.12). Only said when the scene is evidently transparent — no
4238
+ // solid CSS background colour AND nothing in the background layer — so a
4239
+ // generator or pattern backdrop never trips it.
4240
+ function alphaLoss(fmt) {
4241
+ try {
4242
+ if (['mp4', 'jpg'${format === 'webm' && transparent === true ? '' : ", 'webm'"}${format === 'apng' && transparent === false ? ", 'apng'" : ''}].indexOf(fmt) === -1) return [];
4243
+ const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
4244
+ const hasColor = !!bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg);
4245
+ const hasBgItems = !!(app.patternGroup && app.patternGroup.children && app.patternGroup.children.length);
4246
+ if (hasColor || hasBgItems) return [];
4247
+ return [{ code: 'alpha_dropped', message: fmt === 'apng'
4248
+ ? 'apng with transparent: false and no background colour: the frames are filled BLACK. Set a background colour, or drop transparent: false to keep the alpha.'
4249
+ : fmt === 'jpg'
4250
+ ? 'jpg has no transparency and this scene has no background, so transparent areas are filled white. Use png or webp to keep them.'
4251
+ : fmt + ' is exported without transparency and this scene has no background, so transparent areas come out BLACK. Set a background colour, or export apng (animated, full alpha) or png to keep it.' }];
4252
+ } catch (e) { return []; }
4253
+ }
4254
+ // TEXT THAT DOES NOT FIT (FxTool 661224ef: textOverflowReport) — text past
4255
+ // its fit box at the minimum size, or off the frame. The pre-batch check for
4256
+ // certificates, speaker cards and localised variants, on every export.
4257
+ function textOverflow() {
4258
+ try {
4259
+ const rep = (typeof app.textOverflowReport === 'function' && app.textOverflowReport()) || [];
4260
+ return rep.slice(0, 10).map(function(o) {
4261
+ return { code: 'text_overflow', message: (o.id || 'a text item') + ' (' + JSON.stringify(String(o.content || '').slice(0, 40)) + '): ' + (o.reason || 'overflows'), items: [o.id] };
4262
+ });
4263
+ } catch (e) { return []; }
4264
+ }
3211
4265
  function fidelity(fmt) {
4266
+ const own = standIns().concat(alphaLoss(fmt), textOverflow());
3212
4267
  try {
3213
- if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') return {};
4268
+ if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') {
4269
+ return { fidelity: { available: false, reason: 'this studio cannot check export fidelity — update PinePaper Studio.', ...(own.length ? { warnings: own } : {}) } };
4270
+ }
3214
4271
  const r = app.exportEngine.exportFidelity(fmt);
3215
- if (!r || !r.warnings || r.warnings.length === 0) return {};
3216
- return { fidelity: { warnings: r.warnings, checked: r.checked } };
3217
- } catch (e) { return {}; }
4272
+ if (!r) { return { fidelity: { available: false, reason: 'the fidelity check returned nothing.', ...(own.length ? { warnings: own } : {}) } }; }
4273
+ const warnings = (r.warnings || []).concat(own);
4274
+ return {
4275
+ fidelity: {
4276
+ available: true,
4277
+ warnings: warnings,
4278
+ checked: r.checked,
4279
+ ...(warnings.length === 0
4280
+ ? { note: 'this scene loses nothing to ' + fmt + '. That is a statement about THIS SCENE, not a claim that the format is lossless.' }
4281
+ : {}),
4282
+ },
4283
+ };
4284
+ } catch (e) {
4285
+ return { fidelity: { available: false, reason: (e && e.message) || 'the fidelity check threw.', ...(own.length ? { warnings: own } : {}) } };
4286
+ }
3218
4287
  }
3219
4288
 
3220
4289
  // Preflight: same resolved settings as the real export, but render nothing.
@@ -3256,8 +4325,8 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3256
4325
  // drives motion during export — framing only fixes the output frame.
3257
4326
  let cameraDims = null;
3258
4327
  if (framing === 'camera') {
3259
- if (!['gif', 'mp4', 'webm'].includes(format)) {
3260
- return { success: false, platform, format, framing, error: 'framing: "camera" is only supported for video formats (gif, mp4, webm). Use format: "mp4" / "gif" / "webm" or omit framing.' };
4328
+ if (!['gif', 'apng', 'mp4', 'webm'].includes(format)) {
4329
+ return { success: false, platform, format, framing, error: 'framing: "camera" is only supported for animated formats (gif, apng, mp4, webm). Use format: "mp4" / "gif" / "webm" or omit framing.' };
3261
4330
  }
3262
4331
  const rr = app.relationRegistry;
3263
4332
  const hasWalkthrough = rr && typeof rr.hasCameraAnimation === 'function' && rr.hasCameraAnimation();
@@ -3302,10 +4371,65 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3302
4371
  cameraDims = { width: camBase.width, height: camBase.height };
3303
4372
  }
3304
4373
 
3305
- let result = { success: false, platform, format, quality, framing };
4374
+ // AN EXPORTER ANSWERS A BLOB OR A RECORD AROUND ONE.
4375
+ //
4376
+ // Declared ABOVE the switch. They used to be consts inside the video case's
4377
+ // block, so the pdf case — which 04ea8d2 pointed at asBlob — threw
4378
+ // "asBlob is not defined" on every PDF export.
4379
+ //
4380
+ // exportPDF resolves to {blob, width, height, format}, and this passed
4381
+ // the whole record to FileReader.readAsDataURL — "parameter 1 is not of
4382
+ // type 'Blob'", every PDF export, with an error naming FileReader and
4383
+ // nothing about PDFs. The engine has the same normaliser for the same
4384
+ // reason (AgentMode._asBlob), so this matches its behaviour rather than
4385
+ // inventing a second convention.
4386
+ const asBlob = (r) => {
4387
+ if (typeof Blob !== 'undefined' && r instanceof Blob) return r;
4388
+ if (r && typeof Blob !== 'undefined' && r.blob instanceof Blob) return r.blob;
4389
+ // Duck-typed last resort: a studio may hand back something Blob-like
4390
+ // that fails instanceof across a realm boundary.
4391
+ if (r && typeof r.size === 'number' && typeof r.slice === 'function') return r;
4392
+ if (r && r.blob && typeof r.blob.size === 'number') return r.blob;
4393
+ return null;
4394
+ };
4395
+ const blobToDataUrl = (b) => new Promise(resolve => {
4396
+ const r = new FileReader();
4397
+ r.onloadend = () => resolve(r.result);
4398
+ r.readAsDataURL(b);
4399
+ });
3306
4400
 
4401
+ let result = { success: false, platform, format, quality, framing };
4402
+ // A STILL SHOWS WHAT IS ALIVE AT ITS MOMENT. Lifetimes that fall back to the
4403
+ // MCP's frame callback are applied by the frame loop, and a seek does not run
4404
+ // it — so a png at time 0.8 s drew nothing staged at 0.8 s, and an item past
4405
+ // its ttl still showed (retest). The visibility pass runs here for stills.
4406
+ if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(format) !== -1) {
4407
+ const __L = globalThis.__ppMcp;
4408
+ if (__L && __L.lifetimeApp === app && typeof __L.applyLifetimes === 'function') {
4409
+ try { __L.applyLifetimes(${stillTime !== undefined ? stillTime : "typeof app.playbackTime === 'number' ? app.playbackTime : 0"}); } catch (_) { /* the still renders either way */ }
4410
+ }
4411
+ }
4412
+ // The engine's report on the audio track of THIS export (FxTool b41860aa:
4413
+ // encoder delay compensated, padding past the picture dropped). Cleared
4414
+ // first so a report left by an earlier export is never read as this one's.
4415
+ const __vx = app.exportEngine && app.exportEngine.videoExporter;
4416
+ if (__vx && 'lastAudioReport' in __vx) __vx.lastAudioReport = null;
4417
+ if (__vx && 'lastVideoReport' in __vx) __vx.lastVideoReport = null;
4418
+ ${stillTime !== undefined ? `
4419
+ // A STILL AT A CHOSEN MOMENT (round 7 X, 1.57). Without this a png is
4420
+ // whatever frame the playhead is on — two identical builds gave PNGs that
4421
+ // differed in the animated streak while their MP4s were byte-identical.
4422
+ // Seek, render, and put the playhead back afterwards.
4423
+ const __prevT = typeof app.playbackTime === 'number' ? app.playbackTime : 0;
4424
+ if (typeof app.setPlaybackTime !== 'function') {
4425
+ return { success: false, platform, format, error: 'app.setPlaybackTime unavailable — this studio cannot render a still at a chosen time.' };
4426
+ }
4427
+ app.setPlaybackTime(${stillTime});
4428
+ if (typeof paper !== 'undefined' && paper.view && typeof paper.view.update === 'function') paper.view.update();
4429
+ ` : ''}
3307
4430
  try {
3308
- switch (format) {
4431
+ // jpg / webp render as png, then re-encode below.
4432
+ switch ((format === 'jpg' || format === 'webp') ? 'png' : format) {
3309
4433
  case 'svg':
3310
4434
  const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();
3311
4435
  result = {
@@ -3320,6 +4444,59 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3320
4444
  break;
3321
4445
 
3322
4446
  case 'png':
4447
+ ${region ? `// A REGION, not the whole board — carousel slices, crops. renderRegionToDataURL
4448
+ // re-renders that world rect offscreen without touching the live view;
4449
+ // it was reachable only from custom code. A differing output aspect is
4450
+ // covered by the engine, not stretched.
4451
+ if (typeof app.renderRegionToDataURL !== 'function') {
4452
+ result = { success: false, platform, format: 'png', error: 'app.renderRegionToDataURL unavailable — update PinePaper Studio to export a region.' };
4453
+ break;
4454
+ }
4455
+ {
4456
+ const rw = ${region.outputWidth ?? Math.round(region.width)}, rh = ${region.outputHeight ?? Math.round(region.height)};
4457
+ // OWNERSHIP BY CENTRE (1.78, FxTool 78ec5916): excludeForeign hides, for
4458
+ // this render, a top-level item that reaches in but is centred outside —
4459
+ // a neighbouring card's overflowing headline. Older studios ignore the
4460
+ // fourth argument, and leave lastRegionExcluded unset.
4461
+ if ('lastRegionExcluded' in app) app.lastRegionExcluded = null;
4462
+ const regionUrl = app.renderRegionToDataURL(new paper.Rectangle(${region.x}, ${region.y}, ${region.width}, ${region.height}), rw, rh${region.excludeForeign === false ? '' : ', { excludeForeign: true }'});
4463
+ const __excluded = Array.isArray(app.lastRegionExcluded) ? app.lastRegionExcluded.filter(function(id) { return id; }) : null;
4464
+ // ITEMS THAT CROSS THE REGION'S EDGE (round 9 II, 1.78). In the sheet
4465
+ // recipe — one big canvas, one region per card — text that overflows
4466
+ // its cell spills into the next card's export (up to 5,826 foreign px),
4467
+ // silently. A region cannot know which card an item belongs to, so it
4468
+ // cannot clip per card; it can name every item that is only partly
4469
+ // inside it, which is exactly the overflow in either direction.
4470
+ const __rx = ${region.x}, __ry = ${region.y}, __rr = ${region.x + region.width}, __rb = ${region.y + region.height};
4471
+ const crossing = [], coveringExcluded = [];
4472
+ if (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
4473
+ app.itemRegistry.getAll().forEach(function(e) {
4474
+ const b = e && e.item && e.item.visible !== false && e.item.bounds;
4475
+ if (!b || !(b.width > 0)) return;
4476
+ const r = b.x + b.width, bt = b.y + b.height;
4477
+ const intersects = b.x < __rr && r > __rx && b.y < __rb && bt > __ry;
4478
+ const inside = b.x >= __rx - 0.5 && r <= __rr + 0.5 && b.y >= __ry - 0.5 && bt <= __rb + 0.5;
4479
+ const covers = b.x <= __rx && r >= __rr && b.y <= __ry && bt >= __rb; // a full-bleed backdrop is not overflow
4480
+ if (intersects && !inside && !covers && crossing.length < 20) crossing.push(e.itemId);
4481
+ if (covers && __excluded && (__excluded.indexOf(e.itemId) >= 0 || (e.item.data && __excluded.indexOf(e.item.data.id) >= 0))) coveringExcluded.push(e.itemId);
4482
+ });
4483
+ }
4484
+ result = { success: true, platform, format: 'png', data: regionUrl, mimeType: 'image/png',
4485
+ size: Math.round(String(regionUrl).length * 0.75), dimensions: { width: rw, height: rh },
4486
+ region: ${JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height })} };
4487
+ if (__excluded && __excluded.length) {
4488
+ result.excludedItems = __excluded;
4489
+ result.excludedNote = 'centred outside this region, so treated as belonging to a neighbouring region and left out of this image.';
4490
+ }
4491
+ if (coveringExcluded.length) {
4492
+ 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.';
4493
+ }
4494
+ if (crossing.length) {
4495
+ result.crossingItems = crossing;
4496
+ 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.';
4497
+ }
4498
+ }
4499
+ break;` : ''}
3323
4500
  if (app.exportEngine && app.exportEngine.exportPNG) {
3324
4501
  // PNG IGNORED THE PLATFORM ENTIRELY.
3325
4502
  //
@@ -3367,10 +4544,52 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3367
4544
  }
3368
4545
  break;
3369
4546
 
4547
+ case 'html5-ad':
4548
+ case 'playable': {
4549
+ // AN AD FROM THE WIDGET PAGE (8.21 / 8.24). The page is the studio's
4550
+ // standalone widget; the server adds the network's wrapper and builds
4551
+ // the zip (src/utils/ad-package.ts). Here: the page, its size, the CTA
4552
+ // box in percent of the canvas, and a backup image for html5-ad.
4553
+ if (!app.exportEngine || typeof app.exportEngine.exportWidgetHTML !== 'function') {
4554
+ result = { success: false, platform, format, error: 'this studio cannot export its standalone widget page (no exportWidgetHTML), which an ad is built from.' };
4555
+ break;
4556
+ }
4557
+ // The artboard, never the canvas element: its backing store is scaled
4558
+ // by the renderer, and an ad's size is what was asked for.
4559
+ const __as = (typeof app.getCanvasSize === 'function' && app.getCanvasSize()) || app.canvasSize || { width: 0, height: 0 };
4560
+ const __cw = __as.width, __ch = __as.height;
4561
+ let __cta = null;
4562
+ ${adOpts?.ctaItemId ? `{
4563
+ const __ce = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(adOpts.ctaItemId)});
4564
+ const __cb = __ce && __ce.item && __ce.item.bounds;
4565
+ if (!__cb || !(__cb.width > 0) || !(__cw > 0)) {
4566
+ result = { success: false, platform, format, error: ${JSON.stringify(`ad.ctaItemId "${adOpts.ctaItemId}" is not an item on the canvas with a size — nothing was exported. List items with pinepaper_get_items.`)} };
4567
+ break;
4568
+ }
4569
+ __cta = { left: __cb.x / __cw * 100, top: __cb.y / __ch * 100, width: __cb.width / __cw * 100, height: __cb.height / __ch * 100 };
4570
+ }` : ''}
4571
+ const __w = await app.exportEngine.exportWidgetHTML({ download: false });
4572
+ if (!__w || typeof __w.html !== 'string' || !__w.html) {
4573
+ result = { success: false, platform, format, error: 'the studio returned no widget page to build the ad from.' };
4574
+ break;
4575
+ }
4576
+ let __backup = null;
4577
+ if (format === 'html5-ad' && typeof app.captureFrameDataURL === 'function') {
4578
+ try { __backup = app.captureFrameDataURL(1); } catch (_) { __backup = null; }
4579
+ }
4580
+ // The background the PNG export uses (the canvas element's). The widget
4581
+ // page prefers another record that can be stale, which served a white
4582
+ // page under white text (8.9 again); the server pins this one.
4583
+ const __bgc = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
4584
+ result = { success: true, platform, format, adPage: __w.html, adSize: { width: __cw, height: __ch }, cta: __cta, backupImage: __backup, background: __bgc };
4585
+ break;
4586
+ }
4587
+
3370
4588
  case 'gif':
4589
+ case 'apng':
3371
4590
  case 'mp4':
3372
4591
  case 'webm': {
3373
- const videoMimeType = { mp4: 'video/mp4', webm: 'video/webm', gif: 'image/gif' }[format];
4592
+ const videoMimeType = { mp4: 'video/mp4', webm: 'video/webm', gif: 'image/gif', apng: 'image/apng' }[format];
3374
4593
  // FxTool VideoExporter._calculateBitrate multiplies a per-resolution
3375
4594
  // base by settings.quality (0.5-1.0). Our existing per-tier compression
3376
4595
  // values (0.6 / 0.85 / 0.95) map directly to that scale. Without this,
@@ -3380,12 +4599,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3380
4599
  // width/height ride along ONLY when a scale was asked for. The buffered
3381
4600
  // fallback paths otherwise export at canvas size, and quietly changing
3382
4601
  // that for every existing caller is not what a new optional knob does.
3383
- const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration}${scale !== undefined && scale !== 1 ? ', width: dimensions.width, height: dimensions.height' : ''} };
3384
- const blobToDataUrl = (b) => new Promise(resolve => {
3385
- const r = new FileReader();
3386
- r.onloadend = () => resolve(r.result);
3387
- r.readAsDataURL(b);
3388
- });
4602
+ // loop rides in the BASE settings for a gif, so every route carries it:
4603
+ // the direct one below, and also camera framing and a studio without
4604
+ // _quickExportVideo, which build from these and used to drop it.
4605
+ const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration}${scale !== undefined && scale !== 1 ? ', width: dimensions.width, height: dimensions.height' : ''}${gifLoop !== undefined ? `, ...(format === 'gif' || format === 'apng' ? { loop: ${JSON.stringify(gifLoop)} } : {})` : ''}${encodeOptsJs}${transparent !== undefined ? `, transparent: ${transparent}` : ''} };
3389
4606
 
3390
4607
  // THE EXPORT STORE. A long export cannot come back as one base64
3391
4608
  // string: base64 of a gigabyte is larger than the gigabyte, and it
@@ -3403,7 +4620,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3403
4620
  && typeof app.exportEngine.readExport === 'function')
3404
4621
  ? app.exportEngine : null;
3405
4622
 
3406
- if (store && format !== 'gif') {
4623
+ if (store && format !== 'gif' && format !== 'apng') {
3407
4624
  const stored = await store.exportToStore({
3408
4625
  format,
3409
4626
  duration: baseVideoSettings.duration,
@@ -3414,7 +4631,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3414
4631
  // VideoEncoder.configure.
3415
4632
  quality: settings.compression,
3416
4633
  width: cameraDims ? cameraDims.width : dimensions.width,
3417
- height: cameraDims ? cameraDims.height : dimensions.height,
4634
+ height: cameraDims ? cameraDims.height : dimensions.height,${encodeOptsJs ? `
4635
+ // Broadcast / bitrate. A store that rebuilds its options without
4636
+ // them is caught by the video report below, not assumed.
4637
+ ${encodeOptsJs.slice(2)},` : ''}
3418
4638
  });
3419
4639
  if (!stored || stored.ok === false) {
3420
4640
  result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why' };
@@ -3450,7 +4670,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3450
4670
  // refusing.
3451
4671
  const INLINE_MAX_BYTES = ${INLINE_MAX_BYTES};
3452
4672
  const mb = (n) => (n / (1024 * 1024)).toFixed(1);
3453
- const deliver = async (blob) => {
4673
+ const deliver = async (raw) => {
4674
+ // Normalise first: a {blob, …} record would otherwise be reported as
4675
+ // "streamed to a file instead of returning bytes", which is a true
4676
+ // sentence about the wrong problem.
4677
+ const blob = asBlob(raw) || raw;
3454
4678
  if (!blob || typeof blob.size !== 'number' || typeof blob.slice !== 'function') {
3455
4679
  return {
3456
4680
  success: false, platform, format,
@@ -3469,7 +4693,20 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3469
4693
 
3470
4694
  // Camera framing requires going direct to videoExporter so width/height
3471
4695
  // pass through — _quickExportVideo strips dim fields.
3472
- if (cameraDims && app.exportEngine && app.exportEngine.videoExporter) {
4696
+ // ANIMATED PNG (8.12, FxTool 76312a55): stepped PNG frames with full
4697
+ // alpha, transparent unless transparent: false. Asked of the exporter
4698
+ // directly; a studio that lacks it says so in its capability table, and
4699
+ // is refused by name rather than handed a format it would reject.
4700
+ const __apngVx = format === 'apng' ? (app.exportEngine && app.exportEngine.videoExporter) : null;
4701
+ if (format === 'apng' && !(__apngVx && typeof __apngVx.export === 'function' && __apngVx.constructor
4702
+ && typeof __apngVx.constructor.getCapabilities === 'function' && __apngVx.constructor.getCapabilities().apng)) {
4703
+ result = { success: false, platform, format, error: 'this studio cannot export APNG (it needs an engine with animated PNG). For animation with alpha, export a png sequence (pinepaper_interchange export_png_sequence with options.transparent: true); gif keeps only 1-bit transparency.' };
4704
+ } else if (format === 'apng') {
4705
+ const blob = await __apngVx.export(cameraDims ? { ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height } : baseVideoSettings);
4706
+ result = await deliver(blob);
4707
+ if (result.success) result.transparent = ${transparent !== false};
4708
+ if (result.success && cameraDims) result.dimensions = cameraDims;
4709
+ } else if (cameraDims && app.exportEngine && app.exportEngine.videoExporter) {
3473
4710
  const blob = await app.exportEngine.videoExporter.export({ ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height });
3474
4711
  result = await deliver(blob);
3475
4712
  if (result.success) result.dimensions = cameraDims;
@@ -3481,12 +4718,43 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3481
4718
  } else if (app.exportEngine && app.exportEngine._quickExportVideo) {
3482
4719
  // GIF path (working): _quickExportVideo forwards gifQuality to
3483
4720
  // gif.js. Also serves as the fallback if videoExporter is absent.
3484
- const videoResult = await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);
4721
+ ${gifLoop !== undefined ? `// A LOOP COUNT (8.30): _quickExportVideo rebuilds its settings without
4722
+ // it, so a GIF with loop set goes straight to videoExporter.export.
4723
+ // true = forever, false / 0 / 1 = once, n = n plays (engine 952d6d35).
4724
+ const videoResult = (format === 'gif' && app.exportEngine.videoExporter && typeof app.exportEngine.videoExporter.export === 'function')
4725
+ ? { blob: await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', loop: ${JSON.stringify(gifLoop)} })) }
4726
+ : await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);` : `const videoResult = await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);`}
3485
4727
  if (videoResult && videoResult.blob) {
3486
4728
  result = await deliver(videoResult.blob);
3487
4729
  } else {
3488
4730
  result = { success: false, error: format.toUpperCase() + ' export returned no data' };
3489
4731
  }
4732
+ ${maxBytes !== undefined ? `// A BYTE BUDGET (round 9 GG, 8.31 — email wants <= 1 MB). gif.js has no
4733
+ // colour-count knob, so size is the lever: GIF bytes scale roughly with
4734
+ // pixel count, so the frame is shrunk by sqrt(budget / size) with a
4735
+ // margin and re-encoded, at most twice. Every attempt is reported.
4736
+ if (format === 'gif' && result && result.success && result.size > ${maxBytes}) {
4737
+ const attempts = [{ width: dimensions.width, height: dimensions.height, bytes: result.size }];
4738
+ let w = dimensions.width, h = dimensions.height;
4739
+ for (let k = 0; k < 2 && result.success && result.size > ${maxBytes}; k++) {
4740
+ const f = Math.sqrt(${maxBytes} / result.size) * 0.9;
4741
+ w = Math.max(16, Math.round(w * f / 2) * 2); h = Math.max(16, Math.round(h * f / 2) * 2);
4742
+ // videoExporter.export, not _quickExportVideo: the latter rebuilds
4743
+ // its settings without width / height, so a "smaller" retry
4744
+ // through it came out the same size.
4745
+ if (!app.exportEngine.videoExporter || typeof app.exportEngine.videoExporter.export !== 'function') break;
4746
+ const again = await app.exportEngine.videoExporter.export(Object.assign({}, baseVideoSettings, { format: 'gif', width: w, height: h }${gifLoop !== undefined ? `, { loop: ${JSON.stringify(gifLoop)} }` : ''}));
4747
+ if (!again) break;
4748
+ result = await deliver(again);
4749
+ attempts.push({ width: w, height: h, bytes: result.size });
4750
+ }
4751
+ result.budget = { maxBytes: ${maxBytes}, met: !!(result.success && result.size <= ${maxBytes}), attempts: attempts };
4752
+ if (result.success && result.size > ${maxBytes}) {
4753
+ result.warning = 'still ' + result.size + ' bytes after shrinking to ' + w + 'x' + h + ' — over the ' + ${maxBytes} + '-byte budget. Lower fps, shorten duration, or use quality "draft".';
4754
+ } else if (result.success) {
4755
+ result.dimensions = { width: w, height: h };
4756
+ }
4757
+ }` : ''}
3490
4758
  } else if (app.exportEngine && app.exportEngine.videoExporter) {
3491
4759
  const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
3492
4760
  result = await deliver(blob);
@@ -3497,14 +4765,156 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3497
4765
  }
3498
4766
 
3499
4767
  case 'pdf':
4768
+ ${pdfOpts?.pages ? `// ONE PAGE PER SCENE (round 7 Z, 8.16). exportPDF renders the current
4769
+ // canvas only, so a deck of 8 saved scenes exported 1 page. Built here
4770
+ // the way exportPDF builds its one page — the engine's jsPDF, a frame
4771
+ // from captureFrameDataURL, the scene's own size at the canvas DPI —
4772
+ // one scene at a time, and the scene the user was on is put back.
4773
+ {
4774
+ const SM = app.sceneManager;
4775
+ if (!SM || typeof SM.listScenes !== 'function' || typeof SM.loadScene !== 'function') {
4776
+ result = { success: false, platform, format: 'pdf', error: 'scenes are unavailable on this studio (app.sceneManager) — a multi-page PDF needs saved scenes.' }; break;
4777
+ }
4778
+ if (!app.exportEngine || typeof app.exportEngine._loadPDFLibraries !== 'function' || typeof app.captureFrameDataURL !== 'function') {
4779
+ result = { success: false, platform, format: 'pdf', error: 'this studio cannot build a multi-page PDF (no PDF library loader or frame capture) — update PinePaper Studio.' }; break;
4780
+ }
4781
+ const all = SM.listScenes();
4782
+ const wanted = ${JSON.stringify(pdfOpts.pages)};
4783
+ // A page is { id, size? }. Scenes do not record their canvas size —
4784
+ // their "dimensions" is the browser viewport and loadScene does not
4785
+ // resize the canvas — so without a size every page is the current
4786
+ // canvas (the retest: three pages, all square).
4787
+ const pageSpecs = wanted === 'scenes'
4788
+ ? all.map(function(sc) { return { id: sc.id }; })
4789
+ : wanted.map(function(w) { return typeof w === 'string' ? { id: w } : { id: w.sceneId, size: { width: w.width, height: w.height } }; });
4790
+ const ids = pageSpecs.map(function(p) { return p.id; });
4791
+ const sized = pageSpecs.some(function(p) { return p.size; });
4792
+ const missing = ids.filter(function(id) { return !all.some(function(sc) { return sc.id === id; }); });
4793
+ if (!ids.length || missing.length) {
4794
+ result = { success: false, platform, format: 'pdf', error: ids.length ? 'no saved scene ' + missing.join(', ') + '. Saved: ' + all.map(function(sc) { return sc.id; }).join(', ') : 'there are no saved scenes — save each page with pinepaper_manage_scenes first.' }; break;
4795
+ }
4796
+ const back = SM.currentSceneId;
4797
+ const backSize = app.canvasSize ? { width: app.canvasSize.width, height: app.canvasSize.height } : null;
4798
+ const lib = await app.exportEngine._loadPDFLibraries();
4799
+ const targetDpi = ${pdfOpts.dpi ?? 'settings.dpi'};
4800
+ // JPEG pages: a PNG per page made a 3-slide deck 34 MB.
4801
+ const toJpeg = function(url) {
4802
+ return new Promise(function(resolve) {
4803
+ const img = new Image();
4804
+ img.onload = function() {
4805
+ const c = document.createElement('canvas');
4806
+ c.width = img.naturalWidth; c.height = img.naturalHeight;
4807
+ const x = c.getContext('2d');
4808
+ x.fillStyle = '#ffffff'; x.fillRect(0, 0, c.width, c.height);
4809
+ x.drawImage(img, 0, 0);
4810
+ resolve(c.toDataURL('image/jpeg', settings.compression));
4811
+ };
4812
+ img.onerror = function() { resolve(null); };
4813
+ img.src = url;
4814
+ });
4815
+ };
4816
+ let doc = null;
4817
+ const pagesOut = [];
4818
+ // A SEARCHABLE TEXT LAYER PER PAGE (8.10, FxTool 3e22c0df): the
4819
+ // engine lays the loaded scene's text invisibly over the page box;
4820
+ // the per-page reports are summed into result.pdf.
4821
+ const __wantLayer = ${pdfOpts.searchableText !== false};
4822
+ const __layer = __wantLayer && typeof app.exportEngine.addTextLayer === 'function';
4823
+ const __tl = { written: 0, skipped: 0, reasons: [], fonts: null };
4824
+ try {
4825
+ for (const spec of pageSpecs) {
4826
+ const id = spec.id;
4827
+ await SM.loadScene(id);
4828
+ if (spec.size && typeof app.setCanvasSize === 'function') app.setCanvasSize({ width: spec.size.width, height: spec.size.height });
4829
+ const cs = app.canvasSize || { width: app.canvasEl.width, height: app.canvasEl.height };
4830
+ const dpi = (typeof app.getDPI === 'function' && app.getDPI()) || 96;
4831
+ const wMM = cs.width / dpi * 25.4, hMM = cs.height / dpi * 25.4;
4832
+ const orient = wMM > hMM ? 'landscape' : 'portrait';
4833
+ const png = app.captureFrameDataURL(Math.max(1, targetDpi / dpi));
4834
+ const jpg = await toJpeg(png);
4835
+ const __first = !doc;
4836
+ if (!doc) doc = new lib.jsPDF({ orientation: orient, unit: 'mm', format: [wMM, hMM] });
4837
+ else doc.addPage([wMM, hMM], orient);
4838
+ // The scene's fonts (FxTool dbe8441d). Once per document on an
4839
+ // engine whose repeat call re-registers over page 1's face; per
4840
+ // page (this page's scene) where the engine caches faces on the
4841
+ // document — it leaves doc.__ppFontCache after the first call
4842
+ // (3b9499f2), which is the evidence, not a version guess.
4843
+ if (__layer && typeof app.exportEngine.embedTextFonts === 'function' && (__first || doc.__ppFontCache)) {
4844
+ try {
4845
+ const fr = await app.exportEngine.embedTextFonts(doc);
4846
+ if (!__tl.fonts) __tl.fonts = fr;
4847
+ else if (fr) {
4848
+ const seen = {};
4849
+ __tl.fonts.families = (__tl.fonts.families || []).concat(fr.families || []).filter(function(f) {
4850
+ const k = f && (f.family + '|' + f.weight + '|' + f.italic);
4851
+ if (!k || seen[k]) return false; seen[k] = true; return true;
4852
+ });
4853
+ if (typeof fr.embedded === 'number') __tl.fonts.embedded = fr.embedded; // cumulative for the document
4854
+ if (typeof fr.hiddenTextItems === 'number') __tl.fonts.hiddenTextItems = (__tl.fonts.hiddenTextItems || 0) + fr.hiddenTextItems;
4855
+ }
4856
+ } catch (e) { if (!__tl.fonts) __tl.fonts = { embedded: 0, reason: e && e.message }; }
4857
+ }
4858
+ if (jpg) doc.addImage(jpg, 'JPEG', 0, 0, wMM, hMM); else doc.addImage(png, 'PNG', 0, 0, wMM, hMM);
4859
+ if (__layer) {
4860
+ try {
4861
+ const tr = await app.exportEngine.addTextLayer(doc, { x: 0, y: 0, width: wMM, height: hMM });
4862
+ if (tr && tr.ok !== false) { __tl.written += tr.linesWritten || 0; __tl.skipped += tr.linesSkipped || 0; }
4863
+ else __tl.reasons.push(id + ': ' + ((tr && tr.reason) || 'refused'));
4864
+ } catch (e) { __tl.reasons.push(id + ': ' + e.message); }
4865
+ }
4866
+ pagesOut.push({ sceneId: id, widthMM: Math.round(wMM * 10) / 10, heightMM: Math.round(hMM * 10) / 10 });
4867
+ }
4868
+ } finally {
4869
+ if (sized && backSize && typeof app.setCanvasSize === 'function') { try { app.setCanvasSize(backSize); } catch (_) { /* best effort */ } }
4870
+ if (back && typeof SM.loadScene === 'function') { try { await SM.loadScene(back); } catch (_) { /* reported by the page count */ } }
4871
+ }
4872
+ const blob = doc.output('blob');
4873
+ const dataUrl = await blobToDataUrl(blob);
4874
+ result = { success: true, platform, format: 'pdf', data: dataUrl, mimeType: 'application/pdf', size: blob.size, pages: pagesOut.length, pageList: pagesOut,
4875
+ pdf: !__wantLayer ? { searchableText: false, reason: 'off' }
4876
+ : !__layer ? { searchableText: false, reason: 'this studio adds no text layer to multi-page PDFs' }
4877
+ : Object.assign({ searchableText: __tl.written > 0 || __tl.reasons.length < pagesOut.length, linesWritten: __tl.written, linesSkipped: __tl.skipped },
4878
+ __tl.fonts ? { fonts: __tl.fonts } : {},
4879
+ __tl.reasons.length ? { pagesWithoutLayer: __tl.reasons } : {},
4880
+ __tl.written === 0 && __tl.reasons.length === pagesOut.length ? { reason: __tl.reasons.join('; ') } : {}) };
4881
+ if (!sized) result.warning = 'every page used the current canvas size: saved scenes do not record theirs. To give a page its own size, pass pages as [{sceneId, width, height}, …].';
4882
+ }
4883
+ break;` : ''}
3500
4884
  if (app.exportEngine && app.exportEngine.exportPDF) {
3501
- const blob = await app.exportEngine.exportPDF({ dpi: settings.dpi });
3502
- const reader = new FileReader();
3503
- const dataUrl = await new Promise(resolve => {
3504
- reader.onloadend = () => resolve(reader.result);
3505
- reader.readAsDataURL(blob);
4885
+ // Print options reach the engine (they were engine-only before), and
4886
+ // download:false, because the tool delivers the bytes itself.
4887
+ // lastPdfReport is a report PROPERTY (9917cfa5), read through the
4888
+ // engine object like the exporter's audio / video reports, and only
4889
+ // when present.
4890
+ const __pee = app.exportEngine;
4891
+ if ('lastPdfReport' in __pee) __pee.lastPdfReport = null;
4892
+ const pdfOut = await app.exportEngine.exportPDF({
4893
+ dpi: ${pdfOpts?.dpi ?? 'settings.dpi'},
4894
+ download: false,${pdfOpts?.searchableText === false ? '\n searchableText: false,' : ''}${pdfOpts?.paperFormat ? `\n format: ${JSON.stringify(pdfOpts.paperFormat)},` : ''}${pdfOpts?.orientation ? `\n orientation: ${JSON.stringify(pdfOpts.orientation)},` : ''}${pdfOpts?.bleed !== undefined ? `\n includeBleed: ${pdfOpts.bleed > 0}, bleed: ${pdfOpts.bleed},` : ''}${pdfOpts?.trimMarks !== undefined ? `\n trimMarks: ${pdfOpts.trimMarks},` : ''}
3506
4895
  });
3507
- result = { success: true, platform, format: 'pdf', data: dataUrl, mimeType: 'application/pdf', size: blob.size, dpi: settings.dpi };
4896
+ const blob = asBlob(pdfOut);
4897
+ if (!blob) {
4898
+ const got = pdfOut === null ? 'null'
4899
+ : typeof pdfOut === 'object' ? 'an object with keys [' + Object.keys(pdfOut).join(', ') + ']'
4900
+ : typeof pdfOut;
4901
+ result = { success: false, platform, format: 'pdf',
4902
+ error: 'the PDF export did not produce a file: expected a Blob, got ' + got + '.' };
4903
+ break;
4904
+ }
4905
+ const dataUrl = await blobToDataUrl(blob);
4906
+ result = {
4907
+ success: true, platform, format: 'pdf', data: dataUrl,
4908
+ mimeType: 'application/pdf', size: blob.size, dpi: settings.dpi,
4909
+ // The record carries the page geometry the engine chose; it is the
4910
+ // only place a caller can learn it.
4911
+ ...(pdfOut && pdfOut.width ? { dimensions: { width: pdfOut.width, height: pdfOut.height } } : {}),
4912
+ ...(pdfOut && pdfOut.format ? { paperFormat: pdfOut.format } : {}),${pdfOpts?.paperFormat && pdfOpts.paperFormat !== 'custom' && ((pdfOpts.bleed ?? 0) > 0 || pdfOpts.trimMarks) ? `
4913
+ // Bleed and trim marks on a NAMED paper size: the studio keeps the
4914
+ // page at that size and centres the art, so neither appears (prod
4915
+ // retest: MediaBox = TrimBox = A4, no marks). Said, not silent.
4916
+ 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'.`)}],` : ''}
4917
+ };
3508
4918
  } else {
3509
4919
  result = { success: false, error: 'PDF export failed' };
3510
4920
  }
@@ -3520,7 +4930,172 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3520
4930
  // One site, after every branch has set its result: a successful export says
3521
4931
  // what this scene lost to this format, rather than the caller finding out by
3522
4932
  // opening the file. Only when there is something to say — see fidelity().
4933
+ // RE-ENCODE A STILL FOR A BYTE BUDGET. The png path rendered it (whole board
4934
+ // or region); the browser's own encoder makes jpg / webp at the tier's
4935
+ // compression. jpg has no alpha, so it is flattened onto the scene background
4936
+ // (white when there is none) rather than left to the encoder's black.
4937
+ if (result && result.success && (format === 'jpg' || format === 'webp') && typeof result.data === 'string') {
4938
+ const mime = format === 'jpg' ? 'image/jpeg' : 'image/webp';
4939
+ try {
4940
+ const encoded = await new Promise(function(resolve, reject) {
4941
+ const img = new Image();
4942
+ img.onload = function() {
4943
+ const c = document.createElement('canvas');
4944
+ c.width = img.naturalWidth; c.height = img.naturalHeight;
4945
+ const x = c.getContext('2d');
4946
+ // The scene's own background when one is set — white text on a
4947
+ // transparent board vanished into a white flatten. White otherwise.
4948
+ if (mime === 'image/jpeg') {
4949
+ const bg = app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor;
4950
+ x.fillStyle = (bg && !/^(transparent|rgba\\([^)]*,\\s*0\\))$/i.test(bg)) ? bg : '#ffffff';
4951
+ x.fillRect(0, 0, c.width, c.height);
4952
+ }
4953
+ x.drawImage(img, 0, 0);
4954
+ resolve(c.toDataURL(mime, settings.compression));
4955
+ };
4956
+ img.onerror = function() { reject(new Error('the rendered frame could not be decoded for re-encoding')); };
4957
+ img.src = result.data;
4958
+ });
4959
+ if (String(encoded).indexOf('data:' + mime) !== 0) {
4960
+ result = { success: false, platform, format, error: 'this browser cannot encode ' + format + ' (it returned ' + String(encoded).slice(5, 20) + '…).' };
4961
+ } else {
4962
+ result = Object.assign({}, result, { format, data: encoded, mimeType: mime, size: Math.round(String(encoded).length * 0.75), encodeQuality: settings.compression });
4963
+ }
4964
+ } catch (e) {
4965
+ result = { success: false, platform, format, error: (e && e.message) || String(e) };
4966
+ }
4967
+ }
4968
+
3523
4969
  if (result && result.success) Object.assign(result, fidelity(format));
4970
+ if (result && result.success && __fit) result.platformFit = __fit;
4971
+ // AUDIO TIMING, WHERE THE STUDIO REPORTS IT (6.27). An MP4's AAC track used
4972
+ // to start ~44 ms late and run past the picture — a 6.000 s bumper with sound
4973
+ // then failed its cap. The engine now reports the track; it is surfaced as
4974
+ // result.audio, and its warning (sound in the trimmed lead-in) and any audio
4975
+ // longer than the picture go into fidelity.warnings, where a caller looks.
4976
+ // WHAT THE ENCODER WAS GIVEN AND WHAT IT MADE (FxTool 16719759, 8.34-8.36).
4977
+ // The browser's H.264 treats a bitrate as a ceiling, so an 8 Mbps broadcast
4978
+ // request can come out at 1.8 — the engine measures it and warns. A request
4979
+ // the report does not reflect (an engine without the options, or a route
4980
+ // that rebuilt its settings without them) is named, not assumed.
4981
+ // A PDF'S TEXT LAYER (8.10, FxTool 9917cfa5): invisible text at each line's
4982
+ // drawn position, so the PDF is searchable and screen-readable. The built-in
4983
+ // font is WinAnsi only, so Arabic / Hebrew / CJK lines are left out of the
4984
+ // layer (drawn, but not selectable) — counted by the engine, said here.
4985
+ const __ee = app.exportEngine;
4986
+ const __pr = format === 'pdf' && ((result && result.pdf) || (__ee && __ee.lastPdfReport));
4987
+ if (result && result.success && __pr && typeof __pr === 'object') {
4988
+ result.pdf = __pr;
4989
+ const __pw = [];
4990
+ // Which scripts a PDF can carry depends on the studio: older ones have
4991
+ // only the built-in Latin font, newer ones embed the scene's fonts. So the
4992
+ // cause is the engine's report (fonts it could not embed), not a claim here.
4993
+ const __ff = (__pr.fonts && Array.isArray(__pr.fonts.families)) ? __pr.fonts.families.filter(function(f) { return f && f.reason; }) : [];
4994
+ const __fnames = __ff.slice(0, 4).map(function(f) { return f.family + ' (' + f.reason + ')'; }).join('; ');
4995
+ if (__pr.searchableText && __pr.linesSkipped > 0) __pw.push({ code: 'pdf_text_lines_skipped', message: __pr.linesSkipped + ' text line(s) are not in the searchable layer — no font in the PDF covers their characters — so they are drawn but cannot be selected, searched or read by a screen reader.' + (__fnames ? ' Fonts that could not be embedded: ' + __fnames + '.' : '') });
4996
+ // Text hidden at the render moment (a lifetime window, visible: false,
4997
+ // opacity 0) is in neither the picture nor the layer (FxTool 3b9499f2).
4998
+ const __hid = __pr.fonts && typeof __pr.fonts.hiddenTextItems === 'number' ? __pr.fonts.hiddenTextItems : 0;
4999
+ if (__hid > 0) __pw.push({ code: 'pdf_text_hidden', message: __hid + ' text item(s) were hidden when the PDF was rendered (outside their bornAt / ttl window, visible: false, or opacity 0), so they are not in it. Export at a time they show (time), or change their lifetime.' });
5000
+ if (__ff.length && !(__pr.linesSkipped > 0)) __pw.push({ code: 'pdf_font_not_embedded', message: 'these fonts could not be embedded, so their text uses a fallback font in the searchable layer: ' + __fnames + '.' });
5001
+ if (__pr.searchableText && __pr.pagesWithoutLayer) __pw.push({ code: 'pdf_no_text_layer', message: 'some pages have no searchable text layer — ' + __pr.pagesWithoutLayer.join('; ') });
5002
+ if (!__pr.searchableText && __pr.reason && __pr.reason !== 'off') __pw.push({ code: 'pdf_no_text_layer', message: 'no searchable text layer was written (' + __pr.reason + '): the text is drawn but cannot be selected, searched or read by a screen reader.' });
5003
+ if (__pw.length) {
5004
+ result.fidelity = result.fidelity || { warnings: [] };
5005
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__pw);
5006
+ if (result.fidelity.note) delete result.fidelity.note;
5007
+ }
5008
+ }
5009
+ const __vr = __vx && __vx.lastVideoReport;
5010
+ if (result && result.success && __vr && typeof __vr === 'object') result.video = __vr;${webmAlpha ? `
5011
+ // A TRANSPARENT WEBM is confirmed from the encoder's own record: an alpha
5012
+ // stream with frames in it. A studio without WebM alpha writes an opaque
5013
+ // file that looks like it worked.
5014
+ if (result && result.success) {
5015
+ const __al = __vr && __vr.alpha;
5016
+ const __aw = [];
5017
+ if (!(__al && __al.frames > 0)) __aw.push({ code: 'alpha_not_applied', message: 'transparent was asked for, but this studio wrote no alpha stream: the WebM is opaque (transparent areas black). Export apng, or a png sequence, for transparency here.' });
5018
+ else if (__al.keyframeMismatches > 0) __aw.push({ code: 'alpha_keyframe_mismatch', message: __al.keyframeMismatches + ' frame(s) had colour and alpha keyframes out of step; some players may show those frames without transparency.' });
5019
+ if (__aw.length) {
5020
+ result.fidelity = result.fidelity || { warnings: [] };
5021
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__aw);
5022
+ if (result.fidelity.note) delete result.fidelity.note;
5023
+ }
5024
+ }` : ''}${headroomDefaulted ? `
5025
+ // The applied value is the engine's own report where it gives one
5026
+ // (5adfcf1c: lastVideoReport.headroom), so the result and the report agree.
5027
+ if (result && result.success) result.broadcastHeadroom = { value: (__vr && typeof __vr.headroom === 'number') ? __vr.headroom : 12, defaulted: true, note: 'broadcast defaults to 12 luma codes of headroom, which keeps encoder ringing legal; pass broadcastHeadroom: 0 for the full range.' };` : ''}
5028
+ // AN ODD SIDE ROUNDED UP (FxTool e64a5260). H.264 needs even dimensions, so
5029
+ // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The
5030
+ // result's dimensions become what the file IS, and fidelity says why.
5031
+ const __vd = __vr && __vr.dimensions;
5032
+ if (result && result.success && __vd && Array.isArray(__vd.requested) && Array.isArray(__vd.output)
5033
+ && (__vd.requested[0] !== __vd.output[0] || __vd.requested[1] !== __vd.output[1])) {
5034
+ result.dimensions = { width: __vd.output[0], height: __vd.output[1] };
5035
+ result.fidelity = result.fidelity || { warnings: [] };
5036
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'dimensions_rounded',
5037
+ message: 'the file is ' + __vd.output[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).' }]);
5038
+ if (result.fidelity.note) delete result.fidelity.note;
5039
+ }
5040
+ // LUMA OUTSIDE 16-235 (8.35, FxTool 4f1ec3ae). The broadcast encode is
5041
+ // decoded back and measured: H.264 ringing at hard edges puts a fraction of
5042
+ // a percent of samples out of range, which QC rejects. broadcastHeadroom
5043
+ // cuts it but never to zero, so a legaliser pass is the requirement.
5044
+ if (result && result.success && __vr && __vr.luma && __vr.luma.outOfRange > 0 && result.fidelity) {
5045
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'luma_out_of_range',
5046
+ message: 'broadcast QC will reject this as delivered: ' + ((__vr.luma.outOfRangeFraction || 0) * 100).toFixed(3) + '% of decoded luma is outside 16-235 (min ' + __vr.luma.min + ', max ' + __vr.luma.max + ', broadcastHeadroom ' + (typeof __vr.headroom === 'number' ? __vr.headroom : ${JSON.stringify(broadcastHeadroom ?? 12)}) + '). First re-export with a larger broadcastHeadroom (12 measured clean on hard white-on-black edges; up to 40). Otherwise a legaliser pass is REQUIRED before delivery — clip into a NARROWER band and re-encode at high quality, because a plain 16-235 clip followed by a re-encode brings the edge overshoot back: ffmpeg -i in.mp4 -vf "lutyuv=y=clip(val\\\\,28\\\\,223):u=clip(val\\\\,16\\\\,240):v=clip(val\\\\,16\\\\,240)" -c:v libx264 -crf 18 -color_range tv -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a copy out.mp4 (measured 20-229). Verify the luma range after re-encoding.' }]);
5047
+ if (result.fidelity.note) delete result.fidelity.note;
5048
+ }
5049
+ ${Object.keys(videoEncodeOpts).length ? ` if (result && result.success) {
5050
+ const __asked = ${JSON.stringify(videoEncodeOpts)};
5051
+ const __vw = [];
5052
+ // The engine joins its warnings into one string (bitrate floor, then
5053
+ // luma), so each is raised from its own numbers, not from that text.
5054
+ if (__vr && typeof __vr.achievedBitrate === 'number' && typeof __vr.bitrateFloor === 'number' && __vr.achievedBitrate < __vr.bitrateFloor * 0.95) {
5055
+ __vw.push({ code: 'bitrate_below_floor', message: 'achieved ' + (__vr.achievedBitrate / 1e6).toFixed(2) + ' Mbps against a ' + (__vr.bitrateFloor / 1e6).toFixed(1) + ' Mbps floor: the browser encoder treats the bitrate as a ceiling and does not pad simple content. Re-encode for delivery (e.g. ffmpeg -b:v with -minrate / -bufsize) if the spec requires the floor.' });
5056
+ }
5057
+ if (!__vr || typeof __vr !== 'object') {
5058
+ __vw.push({ code: 'encode_options_not_applied', message: 'this studio did not report its encode, so ' + Object.keys(__asked).join(', ') + ' cannot be confirmed — it likely has no broadcast / bitrate support, and the file is an ordinary web encode.' });
5059
+ } else {
5060
+ if (__asked.broadcast && __vr.broadcast !== true) __vw.push({ code: 'broadcast_not_applied', message: 'broadcast was asked for but the encode was not broadcast-safe (the export route dropped the option): full-range, untagged colour.' });
5061
+ if (__asked.bitrate !== undefined && typeof __vr.bitrate === 'number' && __vr.bitrate < __asked.bitrate * 0.99) __vw.push({ code: 'bitrate_not_applied', message: 'bitrate ' + __asked.bitrate + ' was asked for; the encoder was given ' + __vr.bitrate + '.' });
5062
+ }
5063
+ if (__vw.length && result.fidelity) {
5064
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__vw);
5065
+ if (result.fidelity.note) delete result.fidelity.note;
5066
+ }
5067
+ }
5068
+ ` : ''} const __ar = __vx && __vx.lastAudioReport;
5069
+ if (result && result.success && __ar && typeof __ar === 'object') {
5070
+ result.audio = __ar;
5071
+ const __aw = [];
5072
+ if (__ar.warning) __aw.push({ code: 'audio_lead_trimmed', message: String(__ar.warning) });
5073
+ if (typeof __ar.audioDurationS === 'number' && typeof __ar.videoDurationS === 'number' && __ar.audioDurationS > __ar.videoDurationS + 0.0105) {
5074
+ __aw.push({ code: 'audio_longer_than_video', message: 'the audio track is ' + __ar.audioDurationS.toFixed(4) + ' s against ' + __ar.videoDurationS.toFixed(4) + ' s of picture — a platform with a hard length cap may read the longer one.' });
5075
+ }
5076
+ if (__aw.length && result.fidelity) {
5077
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat(__aw);
5078
+ if (result.fidelity.note) delete result.fidelity.note;
5079
+ }
5080
+ }
5081
+ ${(() => {
5082
+ // A DURATION THAT OVERRUNS AT AN NTSC RATE (round 9 FF, 1.76): 6 s at
5083
+ // 29.97 is round(6 x 30000/1001) = 180 frames = 6.006 s, over a 6.000 s
5084
+ // bumper cap, silently. Said, with the longest duration that fits.
5085
+ if (fps === undefined || Number.isInteger(fps))
5086
+ return '';
5087
+ const frames = Math.round(videoDuration * fps);
5088
+ const actual = frames / fps;
5089
+ if (actual <= videoDuration + 1e-9)
5090
+ return '';
5091
+ const fit = Math.floor(videoDuration * fps) / fps;
5092
+ return ` if (result && result.success && ['mp4', 'webm', 'gif', 'apng'].indexOf(format) !== -1) result.timing = ${JSON.stringify({
5093
+ frames, seconds: Math.round(actual * 1e6) / 1e6, requested: videoDuration,
5094
+ 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).`,
5095
+ })};\n`;
5096
+ })()}
5097
+ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
5098
+ if (result && result.success) result.time = ${stillTime};` : ''}
3524
5099
 
3525
5100
  return result;
3526
5101
  })();
@@ -5256,7 +6831,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5256
6831
  `.trim();
5257
6832
  }
5258
6833
  generateImportImage(input) {
5259
- const { url, position, maxWidth, maxHeight, mask } = input;
6834
+ const { url, position, maxWidth, maxHeight, mask, smoothing } = input;
5260
6835
  // Each fragment used to be written with a LEADING comma, as if it followed
5261
6836
  // an entry that no longer exists — so the first one opened the object with
5262
6837
  // `{, position: …}` and the emitted code was a syntax error. Passing any
@@ -5325,7 +6900,10 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5325
6900
  const entry = await app.imageTools.uploadFromURL(src);
5326
6901
  const opts = ${optsLiteral};
5327
6902
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
5328
- ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
6903
+ ${smoothing ? ` // Pixel art wants 'off': Paper's Raster draws with smoothing 'low' by
6904
+ // default, which blurs every scaled-up pixel edge. Read at every draw,
6905
+ // export frames included. Set before a mask, which clones the raster.
6906
+ if (raster && 'smoothing' in raster) raster.smoothing = ${JSON.stringify(smoothing)};\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
5329
6907
  // fell through to \`raster.id\` — a Paper.js NUMBER — and the handle the tool
5330
6908
  // returned could not be used as a relation endpoint or with modify/animate.
5331
6909
  // Register the raster if placeImage somehow left it unregistered, so the
@@ -5337,11 +6915,28 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5337
6915
  if (!itemId) {
5338
6916
  return { error: 'Image placed but not registered — no usable item id. This is a bug; report the scene.' };
5339
6917
  }
6918
+ let placed = raster;
6919
+ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds a
6920
+ // clipping group from a CLONE and removes the original, so the id and bounds
6921
+ // read off \`raster\` afterwards described an item no longer on the canvas.
6922
+ // The group is what is drawn: the registry id is rebound to it (the
6923
+ // registry's own method for exactly this), and a mask that did not apply is
6924
+ // refused instead of answered with the raster's bounds.
6925
+ const masked = app.imageTools.applyMask(raster, ${JSON.stringify(mask)});
6926
+ if (!masked) {
6927
+ return { success: false, itemId: itemId, error: 'the ${mask} mask was not applied — the image may not be a raster yet. The unmasked image is on the canvas as ' + itemId + '.' };
6928
+ }
6929
+ if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
6930
+ placed = masked;
6931
+ ` : ''} const b = placed.bounds;
6932
+ if (!b || !(b.width > 0) || !(b.height > 0)) {
6933
+ return { success: false, itemId: itemId, error: 'the image was placed with no size (' + (b ? b.width + 'x' + b.height : 'no bounds') + ') — it may not have loaded. Nothing visible was drawn.' };
6934
+ }
5340
6935
  return {
5341
6936
  success: true,
5342
6937
  itemId: itemId,
5343
6938
  message: 'Image imported and placed on canvas.',
5344
- bounds: { x: raster.bounds.x, y: raster.bounds.y, width: raster.bounds.width, height: raster.bounds.height }${mask ? `,\n mask: '${mask}'` : ''}
6939
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}
5345
6940
  };
5346
6941
  } catch (e) {
5347
6942
  return { error: 'Failed to import image: ' + e.message };
@@ -5721,9 +7316,27 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5721
7316
  return { success: false, error: 'apply needs itemIds, in the order the stagger should follow (row-major for a grid)' };
5722
7317
  }
5723
7318
  const r = app.staggerItems(ids, ${S(validated.opts || {})});
7319
+ // KEYFRAME TRACKS DO NOT READ animationDelay (round 7 X, 2.2). staggerItems
7320
+ // writes the delay to data.animationDelay, which the loop presets honour;
7321
+ // a tile that already had a keyframe fly-in ignored it, and all 64 tiles of
7322
+ // a shatter mosaic arrived on the same frames. A keyframed item's clock is
7323
+ // its clip offset, data.timeOffset, so the delay goes there too — on top of
7324
+ // the offset it had before the first stagger (remembered), so re-staggering
7325
+ // replaces rather than stacks.
7326
+ let shiftedTracks = 0;
7327
+ if (r && r.ok && Array.isArray(r.delays)) {
7328
+ const items = ids.map(function(id) { const e = app.itemRegistry && app.itemRegistry.get(id); return e && e.item; }).filter(Boolean);
7329
+ items.forEach(function(item, i) {
7330
+ if (!item.data || !Array.isArray(item.data.keyframes) || !item.data.keyframes.length) return;
7331
+ if (item.data._staggerBaseOffset === undefined) item.data._staggerBaseOffset = Number(item.data.timeOffset) || 0;
7332
+ item.data.timeOffset = item.data._staggerBaseOffset + (Number(r.delays[i]) || 0);
7333
+ shiftedTracks++;
7334
+ });
7335
+ }
5724
7336
  return {
5725
7337
  success: r && r.ok === true,
5726
7338
  action: 'apply',
7339
+ shiftedKeyframeTracks: shiftedTracks,
5727
7340
  // 'applied' can be lower than the ids given — an id that resolves to
5728
7341
  // nothing is skipped, and a stagger that silently covered fewer items than
5729
7342
  // asked for is the failure worth seeing.
@@ -5890,7 +7503,25 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5890
7503
  generateBrandKit(input) {
5891
7504
  const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
5892
7505
  const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
5893
- return this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
7506
+ const facade = this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
7507
+ // A KIT'S FONTS ARE LOADED BEFORE IT RESTYLES TEXT — the same fallback as
7508
+ // create_item (see emitEnsureFont): the kit named Anton, the headings were
7509
+ // re-measured in Times. Each family is checked, loaded if missing, and the
7510
+ // outcome for any that needed it rides on the facade's own result.
7511
+ const fonts = input.kit.fonts;
7512
+ const families = [...new Set(Object.values(fonts ?? {}).filter((f) => typeof f === 'string' && f.trim() !== ''))];
7513
+ if (input.action !== 'apply' || families.length === 0)
7514
+ return facade;
7515
+ const loads = families.map((f) => ` r = await (async function() {${emitEnsureFont(f).replace(/\n/g, '\n ')}\n return __font;\n })();\n if (r) __fonts.push(r);`).join('\n');
7516
+ return `
7517
+ // Brand kit: load the kit's fonts, then apply
7518
+ (async function() {
7519
+ const __fonts = [];
7520
+ let r;
7521
+ ${loads}
7522
+ const out = await ${facade.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
7523
+ return __fonts.length && out && typeof out === 'object' ? { ...out, fonts: __fonts } : out;
7524
+ })();`.trim();
5894
7525
  }
5895
7526
  generateComponent(input) {
5896
7527
  const ids = JSON.stringify(input.itemIds || []);
@@ -5909,13 +7540,55 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5909
7540
  const r = app.defineComponent(items, ${JSON.stringify({ name: input.name })});
5910
7541
  return { success: r.ok !== false, ...r };
5911
7542
  })();`.trim();
7543
+ // A COMPONENT'S PARTS, BY THE KEY AN OVERRIDE NEEDS (round 9 II, 1.80).
7544
+ // Overrides are {componentKey: {prop: value}}, and the keys are generated
7545
+ // names (ck_12) — list gave only a count and instantiate returned a raw
7546
+ // Paper item with no id, so the component route to batch creative was
7547
+ // unusable. Both now return parts: each key with its part's type, name and
7548
+ // text, which is how a caller finds the headline's key.
5912
7549
  case 'list':
5913
- return this._facadeCall('listComponents', '', 'Component: list');
7550
+ return `
7551
+ // Component: list, with each definition's parts
7552
+ (function() {
7553
+ if (typeof app.listComponents !== 'function') return { success: false, error: 'app.listComponents() unavailable — update PinePaper Studio.' };
7554
+ const partsOf = function(root) {
7555
+ const out = [];
7556
+ const walk = function(n) { if (!n) return; if (n.data && n.data.componentKey) out.push(Object.assign({ key: n.data.componentKey, type: n.className }, typeof n.content === 'string' ? { content: n.content } : {}, n.name ? { name: n.name } : {})); (n.children || []).forEach(walk); };
7557
+ (root && root.children || []).forEach(walk);
7558
+ return out;
7559
+ };
7560
+ const defs = app._components;
7561
+ const components = app.listComponents().map(function(c) {
7562
+ let parts = null;
7563
+ try {
7564
+ const d = defs && typeof defs.get === 'function' ? defs.get(c.id) : null;
7565
+ if (d && d.json && typeof paper !== 'undefined') {
7566
+ const g = new paper.Group({ insert: false });
7567
+ g.importJSON(d.json);
7568
+ parts = partsOf(g.children.length === 1 && g.children[0].children ? g.children[0] : g);
7569
+ g.remove();
7570
+ }
7571
+ } catch (_) { /* parts unknown on this build */ }
7572
+ return Object.assign({}, c, parts ? { parts: parts } : {});
7573
+ });
7574
+ return { success: true, components: components, count: components.length };
7575
+ })();`.trim();
5914
7576
  case 'instantiate':
5915
- return this._facadeCall('instantiateComponent', `${JSON.stringify(input.componentId || '')}, ${JSON.stringify({
7577
+ return `
7578
+ // Component: instantiate — the instance id, and the keys its overrides use
7579
+ (function() {
7580
+ if (typeof app.instantiateComponent !== 'function') return { success: false, error: 'app.instantiateComponent() unavailable — update PinePaper Studio.' };
7581
+ const r = app.instantiateComponent(${JSON.stringify(input.componentId || '')}, ${JSON.stringify({
5916
7582
  ...(input.position ? { position: input.position } : {}),
5917
7583
  ...(input.overrides ? { overrides: input.overrides } : {}),
5918
- })}`, 'Component: instantiate');
7584
+ })});
7585
+ if (!r || r.ok === false) return { success: false, error: (r && r.error) || 'the component was not placed' };
7586
+ const parts = [];
7587
+ const walk = function(n) { if (!n) return; if (n.data && n.data.componentKey) parts.push(Object.assign({ key: n.data.componentKey, type: n.className }, typeof n.content === 'string' ? { content: n.content } : {}, n.name ? { name: n.name } : {})); (n.children || []).forEach(walk); };
7588
+ walk(r.item);
7589
+ return { success: true, instanceId: r.id, itemId: r.id, parts: parts, orphaned: r.orphaned || [],
7590
+ ...(r.orphaned && r.orphaned.length ? { warning: 'overrides for ' + r.orphaned.map(function(o) { return o.key; }).join(', ') + ' matched no part of this component and were not applied. parts lists the keys it has.' } : {}) };
7591
+ })();`.trim();
5919
7592
  case 'set_override':
5920
7593
  return this._facadeCall('setComponentOverride', `${JSON.stringify(input.instanceId || '')}, ${JSON.stringify(input.componentKey || '')}, ${JSON.stringify(input.prop || '')}, ${JSON.stringify(input.value ?? null)}`, 'Component: set override');
5921
7594
  case 'sync':
@@ -6018,14 +7691,48 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
6018
7691
  }
6019
7692
  }
6020
7693
  generateAudioBeats(input) {
7694
+ // UPLOADED MEDIA IS THE AUDIO A CALLER HAS, AND IT WAS NOT A SOURCE.
7695
+ //
7696
+ // analyzeAudio treats a bare id as a storage asset and wants its data as a
7697
+ // string, so the id or registryId that upload_audio returned gave "could not
7698
+ // decode audio" — while the detector itself is accurate (3.4 ms mean error,
7699
+ // measured). An uploaded clip's player already holds its bytes at a blob:
7700
+ // URL, which analyzeAudio does accept; either id resolves to that here.
7701
+ // Anything else (asset id, data:, URL) passes through untouched.
7702
+ // @engine-methods analyzeAudio animateToBeat
7703
+ const resolveSource = ` let __src = ${JSON.stringify(input.source || '')};
7704
+ const __A = (typeof window !== 'undefined') && window.PinePaperAgent;
7705
+ const __m = (__src && __A && typeof __A.listMedia === 'function')
7706
+ ? __A.listMedia().find(function(x) { return x.id === __src || x.registryId === __src; })
7707
+ : null;
7708
+ if (__m) {
7709
+ const layer = __m.kind === 'video' ? app.videoLayer && app.videoLayer.videos : app.audioLayer && app.audioLayer.audios;
7710
+ const raster = layer && typeof layer.get === 'function' ? layer.get(__m.id) : null;
7711
+ if (!raster || !raster.url) {
7712
+ return { success: false, error: 'uploaded media ' + JSON.stringify(__src) + ' has no playable source to analyse in this studio.' };
7713
+ }
7714
+ __src = raster.url;
7715
+ }`;
7716
+ const call = (method, args, label) => `
7717
+ // ${label}
7718
+ (async function() {
7719
+ if (typeof app.${method} !== 'function') {
7720
+ return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
7721
+ }
7722
+ ${resolveSource}
7723
+ const r = await app.${method}(${args});
7724
+ if (r && typeof r === 'object' && 'ok' in r) {
7725
+ return { success: r.ok !== false, ...r };
7726
+ }
7727
+ return { success: true, result: r };
7728
+ })();`.trim();
6021
7729
  if (input.action === 'analyze') {
6022
- return this._facadeCall('analyzeAudio', `${JSON.stringify(input.source || '')}, ${JSON.stringify({
7730
+ return call('analyzeAudio', `__src, ${JSON.stringify({
6023
7731
  ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
6024
7732
  ...(input.minGap !== undefined ? { minGap: input.minGap } : {}),
6025
7733
  })}`, 'Audio: analyze beats');
6026
7734
  }
6027
- return this._facadeCall('animateToBeat', `${JSON.stringify(input.itemId || '')}, ${JSON.stringify({
6028
- ...(input.source ? { source: input.source } : {}),
7735
+ const opts = JSON.stringify({
6029
7736
  ...(input.beats ? { beats: input.beats } : {}),
6030
7737
  ...(input.grid ? { grid: true } : {}),
6031
7738
  ...(input.property ? { property: input.property } : {}),
@@ -6033,19 +7740,111 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
6033
7740
  ...(input.accent !== undefined ? { accent: input.accent } : {}),
6034
7741
  ...(input.decay !== undefined ? { decay: input.decay } : {}),
6035
7742
  ...(input.sensitivity !== undefined ? { sensitivity: input.sensitivity } : {}),
6036
- })}`, 'Audio: animate to beat');
7743
+ });
7744
+ return call('animateToBeat', `${JSON.stringify(input.itemId || '')}, Object.assign(${opts}, __src ? { source: __src } : {})`, 'Audio: animate to beat');
6037
7745
  }
6038
7746
  generateTemplateParams(input) {
6039
7747
  if (input.action === 'get') {
6040
7748
  return this._facadeCall('getTemplateParams', JSON.stringify(input.templateId), 'Template params: get');
6041
7749
  }
6042
- return this._facadeCall('applyTemplateWithParams', `${JSON.stringify(input.templateId)}, ${JSON.stringify(input.params || {})}`, 'Template params: apply');
7750
+ // AN UNDECLARED PARAM CHANGED NOTHING, AND THE CALL SAID SUCCESS.
7751
+ // apply {templateId:'sale-announcement', params:{headline, date}} loaded
7752
+ // the template, left its text untouched, and answered success:true — and 0
7753
+ // of 195 templates declare any params (round 7 Z, 1.52). The template's own
7754
+ // declaration is read first and anything outside it is named in the result;
7755
+ // the template still loads, because the caller did ask for it.
7756
+ // @engine-methods getTemplateParams applyTemplateWithParams
7757
+ const params = input.params || {};
7758
+ return `
7759
+ // Template params: apply, naming params the template does not declare
7760
+ (async function() {
7761
+ if (typeof app.applyTemplateWithParams !== 'function') {
7762
+ return { success: false, error: 'app.applyTemplateWithParams() unavailable — update PinePaper Studio to a build that has it.' };
7763
+ }
7764
+ const given = ${JSON.stringify(Object.keys(params))};
7765
+ let declared = null;
7766
+ if (typeof app.getTemplateParams === 'function') {
7767
+ try {
7768
+ const d = await app.getTemplateParams(${JSON.stringify(input.templateId)});
7769
+ if (d && Array.isArray(d.params)) declared = d.params.map(function(p) { return p && (p.name || p.key || p.id); }).filter(Boolean);
7770
+ } catch (_) { /* reported as unknown below */ }
7771
+ }
7772
+ const r = await app.applyTemplateWithParams(${JSON.stringify(input.templateId)}, ${JSON.stringify(params)});
7773
+ const out = (r && typeof r === 'object' && 'ok' in r) ? Object.assign({ success: r.ok !== false }, r)
7774
+ : { success: r !== false, templateId: ${JSON.stringify(input.templateId)} };
7775
+ if (given.length && declared) {
7776
+ const ignored = given.filter(function(k) { return declared.indexOf(k) === -1; });
7777
+ if (ignored.length) {
7778
+ out.ignoredParams = ignored;
7779
+ out.declaredParams = declared;
7780
+ out.warning = declared.length === 0
7781
+ ? 'this template declares no parameters, so ' + ignored.join(', ') + ' changed nothing — the template loaded as designed. Edit its text items with pinepaper_modify_item instead.'
7782
+ : ignored.join(', ') + ' ' + (ignored.length > 1 ? 'are' : 'is') + ' not declared by this template and changed nothing. It declares: ' + declared.join(', ') + '.';
7783
+ }
7784
+ } else if (given.length && !declared) {
7785
+ out.warning = 'could not read which parameters this template declares, so whether ' + given.join(', ') + ' applied is unverified.';
7786
+ }
7787
+ return out;
7788
+ })();`.trim();
6043
7789
  }
6044
7790
  generateSceneDiff(input) {
6045
7791
  if (input.action === 'version') {
6046
7792
  return this._facadeCall('diffAgainstVersion', JSON.stringify(input.versionId || ''), 'Scene diff: vs version');
6047
7793
  }
6048
- return this._facadeCall('diffHistoryStates', `${input.indexA ?? 0}, ${input.indexB ?? 0}`, 'Scene diff: history');
7794
+ // "NO CHANGES" AND "THE SNAPSHOTS DO NOT REFLECT THE CANVAS" ARE DIFFERENT
7795
+ // ANSWERS.
7796
+ //
7797
+ // A pilot added six items and six tracks and was told nothing had changed,
7798
+ // with unchanged:0 — the tell, since a real "no changes" compares items and
7799
+ // finds them equal, so it can never report zero of everything.
7800
+ //
7801
+ // I FIRST DIAGNOSED THIS WRONG, and the wrong cause was in this file's
7802
+ // emitted error for one commit. I read that HistoryManager stores entries
7803
+ // as JSON strings and concluded diffScenes never parsed them. It does:
7804
+ // SceneDiff's asObject() JSON.parses a string at the extraction entry, and
7805
+ // fxtool-f2 verified it two ways — a stringified Paper tree diffs correctly,
7806
+ // and a live browser run on string-stored history returned real added ids.
7807
+ //
7808
+ // The actual cause was GeneratedCodeRunner writing its single history entry
7809
+ // only when items were CREATED, so a run that merely applied an effect, a
7810
+ // style or a keyframe track wrote none. The two entries in those histories
7811
+ // were both empty scenes, and two empty snapshots honestly diffed ARE "no
7812
+ // changes". Fixed in FxTool c95c1d72.
7813
+ //
7814
+ // The backstop stays, for a reason that outlives that fix: a snapshot pair
7815
+ // that does not reflect the canvas must never present as an affirmative
7816
+ // nothing-changed. HistoryManager's own _itemIdsOf states the rule —
7817
+ // "null means could not tell, never empty" — and this is the same rule at
7818
+ // the tool boundary. It no longer guesses WHY, because guessing why is
7819
+ // exactly what went wrong.
7820
+ return `
7821
+ // Scene diff: what changed between two history states
7822
+ (function() {
7823
+ if (typeof app.diffHistoryStates !== 'function') {
7824
+ return { success: false, error: 'app.diffHistoryStates unavailable — update PinePaper Studio.' };
7825
+ }
7826
+ const d = app.diffHistoryStates(${input.indexA ?? 0}, ${input.indexB ?? 0});
7827
+ if (!d || d.ok === false) {
7828
+ return { success: false, error: (d && (d.error || d.reason)) || 'the diff refused without saying why' };
7829
+ }
7830
+ const saw = (d.added || []).length + (d.removed || []).length + (d.changed || []).length + (d.unchanged || 0);
7831
+ const live = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function')
7832
+ ? (app.itemRegistry.getAll() || []).length
7833
+ : 0;
7834
+ if (saw === 0 && live > 0) {
7835
+ return {
7836
+ success: false,
7837
+ error: 'the diff read NO items from either history state while the canvas holds ' + live
7838
+ + ' — so those snapshots do not reflect this canvas, which is not the same as "nothing changed". '
7839
+ + 'The usual cause is that no history entry was written for the changes you made: some studio builds '
7840
+ + 'snapshot only when items are CREATED, so applying an effect, a style or a keyframe track records nothing. '
7841
+ + 'Compare with pinepaper_get_items before and after instead, or use action "version".',
7842
+ liveItems: live,
7843
+ raw: d,
7844
+ };
7845
+ }
7846
+ return { success: true, ...d };
7847
+ })();`.trim();
6049
7848
  }
6050
7849
  generateTransform(input) {
6051
7850
  switch (input.action) {
@@ -6167,6 +7966,28 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
6167
7966
  // IMAGE PROCESSING
6168
7967
  // ===========================================================================
6169
7968
  generateImageFilter(input) {
7969
+ // BLUR TAKES `radius`, AND THIS TOOL'S OWN EXAMPLE SAID `amount`.
7970
+ //
7971
+ // FilterSystem's blur reads params.radius and does Math.floor(undefined) =
7972
+ // NaN, which passes its own `radius <= 0` guard and then blurs by nothing.
7973
+ // So {amount: 18} was accepted, ran, and changed no pixels — while the
7974
+ // schema example told callers to write exactly that. The example is fixed;
7975
+ // the alias stays because the wrong spelling was documented and anyone who
7976
+ // followed it should not have to discover the difference twice.
7977
+ //
7978
+ // BLUR ONLY. grain, bloom, chromaticAberration, displace, datamosh and
7979
+ // paletteMap all read a real `amount`; renaming it for them would turn a
7980
+ // working call into the same silent no-op this alias exists to prevent.
7981
+ const blurAlias = (name, params) => {
7982
+ const out = { ...(params ?? {}) };
7983
+ if (name === 'blur' && out.amount !== undefined && out.radius === undefined) {
7984
+ out.radius = out.amount;
7985
+ delete out.amount;
7986
+ }
7987
+ return out;
7988
+ };
7989
+ const filterParams = blurAlias(input.filterName, input.params);
7990
+ const chainFilters = (input.filters || []).map((f) => ({ ...f, params: blurAlias(f.name, f.params) }));
6170
7991
  // Resolve the raster from the registry id (unwrapping groups when the
6171
7992
  // build has the _resolveRaster helper), then use the real GPU-filter
6172
7993
  // facades. The previous emitter called app.imageTools.applyFilter, a
@@ -6183,7 +8004,13 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
6183
8004
  (async function() {
6184
8005
  ${resolve}
6185
8006
  try {
6186
- await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(input.params || {})});
8007
+ const r = await app.applyImageFilter(item, ${JSON.stringify(input.filterName || '')}, ${JSON.stringify(filterParams)});
8008
+ // applyImageFilter THROWS for a missing raster and answers the item
8009
+ // otherwise; a falsy answer is a refusal it did not raise.
8010
+ if (r === false || r === null) {
8011
+ return { success: false, action: 'apply', itemId: ${JSON.stringify(input.itemId)},
8012
+ error: 'the filter did not apply — ${input.filterName || ''} may not be available on this studio.' };
8013
+ }
6187
8014
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
6188
8015
  } catch (e) {
6189
8016
  return { error: 'Failed to apply filter: ' + e.message };
@@ -6195,7 +8022,7 @@ ${resolve}
6195
8022
  (async function() {
6196
8023
  ${resolve}
6197
8024
  try {
6198
- const filters = ${JSON.stringify(input.filters || [])};
8025
+ const filters = ${JSON.stringify(chainFilters)};
6199
8026
  await app.applyImageFilterChain(item, filters);
6200
8027
  return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
6201
8028
  } catch (e) {
@@ -6243,11 +8070,84 @@ ${resolve}
6243
8070
  // a caller in a mode it cannot get out of or complete.
6244
8071
  //
6245
8072
  // Refused by name, with the tools that do the same job without a pointer.
6246
- const why = 'the lasso is an interactive mouse tool (app.lassoTool: activate, then startStroke/continueStroke/endStroke as the user draws). '
6247
- + 'There is no headless call that completes an extraction, so this tool could never work over MCP. '
8073
+ const why = 'activate / apply drive the interactive, mouse-based lasso. Use action "cut" with itemId and points (a polygon in canvas coordinates) — it drives the same lasso headlessly. '
6248
8074
  + 'To cut a region without a pointer, use pinepaper_extract_object (detects and extracts by description), '
6249
8075
  + 'or pinepaper_cutout_style / the mask actions on pinepaper_image_tools to shape an existing raster.';
6250
8076
  switch (input.action) {
8077
+ case 'cut': {
8078
+ // A POLYGON CUT, DRIVEN THROUGH THE LASSO'S OWN CLICK PATH (round 7 X,
8079
+ // 1.55). The interactive tool has no headless form, but its polygon
8080
+ // mode is click-by-click: startStroke/endStroke at a point adds a vertex,
8081
+ // and a click within 12 px of the first vertex closes the shape. That is
8082
+ // public API, so this drives it exactly as a user would, then applies.
8083
+ //
8084
+ // applyLasso DELETES the image it cut from ("chosen behaviour" in the
8085
+ // editor). Over MCP that removed the photo a parallax needs underneath,
8086
+ // silently. keepOriginal (default) cuts from a temporary clone, which is
8087
+ // what gets consumed; the result says which happened.
8088
+ const pts = (input.points ?? []).map((p) => (Array.isArray(p) ? { x: p[0], y: p[1] } : p));
8089
+ if (!input.itemId)
8090
+ return `({ success: false, action: 'cut', error: 'cut needs itemId — the image to cut from.' });`;
8091
+ if (pts.length < 3)
8092
+ return `({ success: false, action: 'cut', error: 'cut needs at least 3 points (canvas coordinates) around the region.' });`;
8093
+ const keep = input.keepOriginal !== false;
8094
+ return `
8095
+ // Lasso cut along a polygon
8096
+ (async function() {
8097
+ const L = app.lassoTool;
8098
+ if (!L || typeof L.activate !== 'function' || typeof L.applyLasso !== 'function') {
8099
+ return { success: false, action: 'cut', error: 'app.lassoTool unavailable — update PinePaper Studio.' };
8100
+ }
8101
+ const entry = app.itemRegistry && app.itemRegistry.get(${JSON.stringify(input.itemId)});
8102
+ const original = entry && entry.item;
8103
+ if (!original) return { success: false, action: 'cut', error: 'no item ' + ${JSON.stringify(input.itemId)} };
8104
+ const target = ${keep} ? original.clone() : original;
8105
+ // A clone carries a COPY of data — including the registry id — and
8106
+ // applyLasso deletes by that id, which would take the original anyway.
8107
+ if (${keep} && target.data) {
8108
+ target.data = Object.assign({}, target.data);
8109
+ delete target.data.registryId; delete target.data.id;
8110
+ }
8111
+ const pts = ${JSON.stringify(pts)}.map(function(p) { return new paper.Point(p.x, p.y); });
8112
+ const first = pts[0];
8113
+ let skipped = 0;
8114
+ L.activate(target);
8115
+ // POLYGON MODE, SET — not assumed. On production the lasso's remembered mode
8116
+ // was 'freehand', where a click adds no vertex, so every cut was refused as
8117
+ // "did not close". The user's own mode is put back afterwards.
8118
+ const __prevMode = L._forcedMode;
8119
+ if (typeof L.setMode === 'function') L.setMode('polygon');
8120
+ try {
8121
+ L.startStroke(first); L.endStroke(first);
8122
+ for (let i = 1; i < pts.length; i++) {
8123
+ if (pts[i].getDistance(first) < 12) { skipped++; continue; }
8124
+ L.startStroke(pts[i]); L.endStroke(pts[i]);
8125
+ }
8126
+ L.startStroke(first); L.endStroke(first); // a click at the first vertex closes it
8127
+ if (!L.isDrawn()) {
8128
+ L.cancel();
8129
+ if (${keep}) target.remove();
8130
+ return { success: false, action: 'cut', error: 'the polygon did not close — it needs at least 3 vertices at least 12 px from the first one.', skipped: skipped };
8131
+ }
8132
+ const cutout = await L.applyLasso();
8133
+ if (!cutout) {
8134
+ if (${keep} && target.parent) target.remove();
8135
+ return { success: false, action: 'cut', error: 'the lasso produced no cutout — the item may not be an image.' };
8136
+ }
8137
+ let cutoutId = cutout.data && (cutout.data.registryId || cutout.data.id);
8138
+ if (!cutoutId && typeof app.registerItem === 'function') cutoutId = app.registerItem(cutout, 'image', { source: 'mcp-lasso' });
8139
+ return { success: true, action: 'cut', cutoutId: cutoutId, originalId: ${JSON.stringify(input.itemId)},
8140
+ originalKept: ${keep}, vertices: pts.length - skipped, skipped: skipped,
8141
+ bounds: { x: cutout.bounds.x, y: cutout.bounds.y, width: cutout.bounds.width, height: cutout.bounds.height } };
8142
+ } catch (e) {
8143
+ try { L.cancel(); } catch (_) { /* already down */ }
8144
+ if (${keep} && target && target.parent) target.remove();
8145
+ return { success: false, action: 'cut', error: (e && e.message) || String(e) };
8146
+ } finally {
8147
+ if (typeof L.setMode === 'function' && __prevMode) { try { L.setMode(__prevMode); } catch (_) { /* cosmetic */ } }
8148
+ }
8149
+ })();`.trim();
8150
+ }
6251
8151
  case 'activate':
6252
8152
  case 'apply':
6253
8153
  return `
@@ -6914,7 +8814,28 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6914
8814
  (async function() {
6915
8815
  if (!app.exportEngine || !app.exportEngine.exportWidget) return { error: 'Widget export not available' };
6916
8816
  const result = await app.exportEngine.exportWidget(${JSON.stringify(opts)});
6917
- return { success: true, json: result.json, filename: result.filename, embedCode: result.embedCode, metadata: result.data?.metadata };
8817
+ // THE EMBED BOX WAS ALWAYS 16:9, AND IT IS NOT SELF-CONTAINED (round 9 GG,
8818
+ // 8.32). The engine writes aspect-ratio: 16/9 and max-width: 800px whatever
8819
+ // the scene, so a 300x250 ad sat letterboxed in a wide box. The box is sized
8820
+ // from the canvas here. The embed also loads PineWidget.js from
8821
+ // pinepaper.studio and fetches the scene JSON at runtime — said, with the
8822
+ // zero-dependency alternative.
8823
+ const cs = (typeof app.getCanvasSize === 'function' && app.getCanvasSize()) || app.canvasSize || null;
8824
+ let embedCode = String(result.embedCode || '');
8825
+ if (cs && cs.width > 0 && cs.height > 0) {
8826
+ // RegExp from strings with no backslashes — a template literal eats them.
8827
+ embedCode = embedCode
8828
+ .replace(new RegExp('aspect-ratio:[ ]*[0-9.]+[ ]*/[ ]*[0-9.]+'), 'aspect-ratio: ' + cs.width + '/' + cs.height)
8829
+ .replace(new RegExp('max-width:[ ]*[0-9.]+px'), 'max-width: ' + cs.width + 'px');
8830
+ }
8831
+ const deps = [];
8832
+ const scriptSrc = /<script[^>]+src="([^"]+)"/.exec(embedCode);
8833
+ if (scriptSrc) deps.push({ kind: 'script', url: scriptSrc[1] });
8834
+ if (result.filename) deps.push({ kind: 'scene-json', url: result.filename, note: 'fetched at runtime — host it beside the page' });
8835
+ return { success: true, json: result.json, filename: result.filename, embedCode: embedCode, metadata: result.data?.metadata,
8836
+ size: cs ? { width: cs.width, height: cs.height } : null,
8837
+ dependencies: deps,
8838
+ note: 'this embed needs the files in dependencies at runtime. For a single self-contained file with no network calls (ads, email-safe hosting), use pinepaper_export_widget_html.' };
6918
8839
  })();`.trim();
6919
8840
  }
6920
8841
  generateExportWidgetHtml(input) {
@@ -6928,7 +8849,26 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6928
8849
  (async function() {
6929
8850
  if (!app.exportEngine || !app.exportEngine.exportWidgetHTML) return { error: 'Widget HTML export not available' };
6930
8851
  const result = await app.exportEngine.exportWidgetHTML(${JSON.stringify(opts)});
6931
- return { success: true, html: result.html, estimatedSize: result.estimatedSize, analysis: { itemTypes: [...result.analysis.itemTypes], relationTypes: [...result.analysis.relationTypes], hasSimpleAnimations: result.analysis.hasSimpleAnimations, hasKeyframeAnimations: result.analysis.hasKeyframeAnimations, hasMasks: result.analysis.hasMasks } };
8852
+ ${input.lang || input.dir || input.alt ? `// lang / dir / alt on the engine's page (it writes <html lang="en"> and no
8853
+ // text alternative). Applied as string edits on the one <html> tag and the
8854
+ // first <canvas>; the result says which landed.
8855
+ const __esc = function(v) { return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); };
8856
+ let __html = String(result.html || '');
8857
+ const __applied = [];
8858
+ ${input.lang || input.dir ? `__html = __html.replace(/<html\\b[^>]*>/i, function() { __applied.push('html'); return '<html lang="' + __esc(${JSON.stringify(input.lang ?? 'en')}) + '"' + ${JSON.stringify(input.dir ? ` dir="${input.dir}"` : '')} + '>'; });` : ''}
8859
+ ${input.alt ? `// The engine's widget draws its canvas from script, so there is no <canvas>
8860
+ // tag to label (prod retest): its accessible name is div#w's aria-label,
8861
+ // mirrored in the screen-reader text #w-desc. Both carry the alt; a literal
8862
+ // <canvas> is labelled too when a page has one. RegExp from strings, so no
8863
+ // backslash escapes pass through this template.
8864
+ const __alt = __esc(${JSON.stringify(input.alt)});
8865
+ __html = __html.replace(new RegExp('<div id="w"([^>]*?)aria-label="[^"]*"', 'i'), function(m, mid) { __applied.push('widget-label'); return '<div id="w"' + mid + 'aria-label="' + __alt + '"'; });
8866
+ __html = __html.replace(new RegExp('(<div[^>]*id="w-desc"[^>]*>)[^<]*(</div>)', 'i'), function(m, open, close) { __applied.push('widget-desc'); return open + __alt + close; });
8867
+ __html = __html.replace(new RegExp('<canvas(?=[\\\\s>])', 'i'), function(m) { __applied.push('canvas'); return m + ' role="img" aria-label="' + __alt + '"'; });
8868
+ __html = __html.replace(/<head>/i, function(m) { __applied.push('meta'); return m + '<meta name="description" content="' + __esc(${JSON.stringify(input.alt)}) + '">'; });` : ''}
8869
+ result.html = __html;
8870
+ result.a11y = __applied;` : ''}
8871
+ return { success: true, html: result.html,${input.lang || input.dir || input.alt ? ' a11y: result.a11y,' : ''} estimatedSize: result.estimatedSize, analysis: { itemTypes: [...result.analysis.itemTypes], relationTypes: [...result.analysis.relationTypes], hasSimpleAnimations: result.analysis.hasSimpleAnimations, hasKeyframeAnimations: result.analysis.hasKeyframeAnimations, hasMasks: result.analysis.hasMasks } };
6932
8872
  })();`.trim();
6933
8873
  }
6934
8874
  // ===========================================================================
@@ -6942,8 +8882,11 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6942
8882
  return `(function() { return { error: 'chartType is required for create' }; })();`;
6943
8883
  if (!input.data)
6944
8884
  return `(function() { return { error: 'data is required for create' }; })();`;
8885
+ const fields = resolveChartFields(input.chartType, input.data, input.options || {});
8886
+ if ('error' in fields)
8887
+ return `(function() { return { success: false, error: ${JSON.stringify(fields.error)} }; })();`;
6945
8888
  const dataStr = JSON.stringify(input.data);
6946
- const optsStr = JSON.stringify(input.options || {});
8889
+ const optsStr = JSON.stringify(fields.options);
6947
8890
  return `
6948
8891
  // Create chart
6949
8892
  (function() {
@@ -6951,7 +8894,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6951
8894
  const group = app.createChart(${JSON.stringify(input.chartType)}, ${dataStr}, ${optsStr});
6952
8895
  if (!group) return { error: 'Failed to create chart' };
6953
8896
  const chartId = app.chartSystem.getLastChartId();
6954
- return { success: true, action: 'create', chartType: ${JSON.stringify(input.chartType)}, chartId, dataPoints: ${input.data.length} };
8897
+ return { success: true, action: 'create', chartType: ${JSON.stringify(input.chartType)}, chartId, dataPoints: ${input.data.length}${fields.inferred ? `, fields: ${JSON.stringify(fields.inferred)}` : ''} };
6955
8898
  })();`.trim();
6956
8899
  }
6957
8900
  case 'update': {
@@ -7270,6 +9213,23 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7270
9213
  * with asNodes promotes each detection to a typed, image-anchored design node
7271
9214
  * (pp:Detected*) instead of a labeled box. Async (runs an on-device ML model).
7272
9215
  */
9216
+ // THE MODEL CANNOT BE DOWNLOADED, AND THE ERROR SAID ONLY "Failed to fetch".
9217
+ //
9218
+ // Detection runs transformers.js in the page, which pulls the model weights
9219
+ // from huggingface.co. pinepaper.studio's connect-src lists neither that host
9220
+ // nor its CDN (checked against the served header), so the download is refused
9221
+ // for every page on production — an agent read "Failed to fetch
9222
+ // (huggingface.co)" and could not tell whether to retry, pass another image,
9223
+ // or give up. It is a deployment fix (allow the host, or serve the weights
9224
+ // from the studio's own origin), and the error now says so.
9225
+ static MODEL_FETCH_HINT = `function(msg) {
9226
+ if (/fail(ed)? to fetch|networkerror|load failed/i.test(msg) && /huggingface|hf\\.co/i.test(msg)) {
9227
+ return 'the detection model could not be downloaded: the page could not fetch its weights from huggingface.co. '
9228
+ + 'The studio has to allow that host in its content-security policy, or serve the weights itself — no argument to this call works around it, and retrying will not help. '
9229
+ + 'Other image tools are unaffected. (' + msg + ')';
9230
+ }
9231
+ return msg;
9232
+ }`;
7273
9233
  generateDetectObjects(input) {
7274
9234
  const args = JSON.stringify({
7275
9235
  ...(input.itemId !== undefined ? { itemId: input.itemId } : {}),
@@ -7285,8 +9245,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7285
9245
  }
7286
9246
  try {
7287
9247
  const res = await app.detectObjects(${args});
7288
- return Object.assign({ success: !!(res && res.ok) }, res || {});
7289
- } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
9248
+ const out = Object.assign({ success: !!(res && res.ok) }, res || {});
9249
+ if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
9250
+ return out;
9251
+ } catch (e) { return { success: false, error: (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(String((e && e.message) || e)) }; }
7290
9252
  })();`.trim();
7291
9253
  }
7292
9254
  /**
@@ -7309,8 +9271,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7309
9271
  }
7310
9272
  try {
7311
9273
  const res = await app.extractObject(${args});
7312
- return Object.assign({ success: !!(res && res.ok) }, res || {});
7313
- } catch (e) { return { success: false, error: String((e && e.message) || e) }; }
9274
+ const out = Object.assign({ success: !!(res && res.ok) }, res || {});
9275
+ if (!out.success && typeof out.error === 'string') out.error = (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(out.error);
9276
+ return out;
9277
+ } catch (e) { return { success: false, error: (${PinePaperCodeGenerator.MODEL_FETCH_HINT})(String((e && e.message) || e)) }; }
7314
9278
  })();`.trim();
7315
9279
  }
7316
9280
  /**
@@ -7367,7 +9331,10 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7367
9331
  app.groupManager.addItemsToGroup(items, group);
7368
9332
  if (app.historyManager) app.historyManager.saveState();
7369
9333
  const groupId = (group.data && (group.data.registryId || group.data.groupId)) || null;
7370
- return { success: true, groupId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
9334
+ // itemId too (round 8 BB, 1.70): every other create answers itemId, and a
9335
+ // group is an item every other tool addresses by that id. In a batch this
9336
+ // does not claim a $N slot — only create ops do.
9337
+ return { success: true, groupId: groupId, itemId: groupId, groupName: group.data && group.data.groupName, itemCount: items.length };`;
7371
9338
  }
7372
9339
  /**
7373
9340
  * Group a set of items into one draggable entity, or ungroup one back into loose
@@ -7386,7 +9353,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7386
9353
  if (!result || !result.parts || !result.parts.length) return { success: false, error: 'Nothing to break apart (no sub-parts found)' };
7387
9354
  const partIds = result.parts.map(function(p) { return p && p.data && (p.data.registryId || p.data.id); }).filter(Boolean);
7388
9355
  const groupId = (result.group && result.group.data && (result.group.data.registryId || result.group.data.groupId)) || null;
7389
- return { success: true, action: 'break_apart', groupId: groupId, partIds: partIds, partCount: partIds.length };
9356
+ return { success: true, action: 'break_apart', groupId: groupId, itemId: groupId, partIds: partIds, partCount: partIds.length };
7390
9357
  })();`.trim();
7391
9358
  }
7392
9359
  if (input.action === 'ungroup') {
@@ -7442,6 +9409,68 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7442
9409
  * sceneAt(t). Returns a cheap per-frame hash by default (token-light) so an agent can
7443
9410
  * verify determinism (re-run → identical hashes) and whether frames actually change.
7444
9411
  */
9412
+ /**
9413
+ * Measurements for pinepaper_accessibility_check; the verdicts are made on
9414
+ * the server (src/utils/a11y.ts). Text: fill, size, weight, and the solid
9415
+ * fill of the topmost item under its centre. Flash: mean relative luminance
9416
+ * of a 3 x 3 grid per sampled frame, through captureFramesAt (deterministic
9417
+ * seek). Pixel work is native reduce / Array.from, not generated loops, so
9418
+ * the loop budget does not count it.
9419
+ */
9420
+ generateAccessibilityCheck(input) {
9421
+ const checks = input.checks ?? ['contrast', 'flash'];
9422
+ const fps = input.fps ?? 20;
9423
+ const duration = input.duration ?? 5;
9424
+ const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
9425
+ return `
9426
+ // Accessibility measurements (${checks.join(', ')})
9427
+ (function() {
9428
+ const out = { success: true, fps: ${fps}, duration: ${duration} };
9429
+ const rgba = function(c) {
9430
+ if (!c || c.gradient || typeof c.red !== 'number') return null;
9431
+ return { r: Math.round(c.red * 255), g: Math.round(c.green * 255), b: Math.round(c.blue * 255), a: typeof c.alpha === 'number' ? c.alpha : 1 };
9432
+ };
9433
+ out.page = (app.canvasEl && app.canvasEl.style && app.canvasEl.style.backgroundColor) || null;
9434
+ ${checks.includes('contrast') ? ` const all = (app.itemRegistry && typeof app.itemRegistry.getAll === 'function') ? app.itemRegistry.getAll() : [];
9435
+ const texts = all.filter(function(e) { return e && e.item && e.item.className === 'PointText' && e.item.visible !== false && String(e.item.content || '').trim() !== ''; });
9436
+ const solids = all.filter(function(e) { return e && e.item && e.item.className !== 'PointText' && e.item.visible !== false && rgba(e.item.fillColor) && e.item.bounds; });
9437
+ out.texts = texts.map(function(e) {
9438
+ const it = e.item, c = it.bounds.center;
9439
+ const under = solids.filter(function(s) { return s.item.bounds.contains(c) && typeof s.item.isBelow === 'function' && s.item.isBelow(it); });
9440
+ const top = under.reduce(function(best, s) { return (!best || (typeof s.item.isAbove === 'function' && s.item.isAbove(best.item))) ? s : best; }, null);
9441
+ const w = String(it.fontWeight || '');
9442
+ return { id: e.itemId, content: String(it.content), fill: rgba(it.fillColor), fontSize: it.fontSize || 0,
9443
+ bold: w === 'bold' || Number(w) >= 700, behind: top ? rgba(top.item.fillColor) : null, behindId: top ? top.itemId : undefined };
9444
+ });
9445
+ ` : ''}${checks.includes('flash') ? ` if (typeof app.captureFramesAt !== 'function') {
9446
+ out.flashUnavailable = 'this studio has no deterministic frame capture (app.captureFramesAt), so flashing was not checked.';
9447
+ } else {
9448
+ const W = 48, H = 27;
9449
+ const small = document.createElement('canvas');
9450
+ small.width = W; small.height = H;
9451
+ const ctx = small.getContext('2d', { willReadFrequently: true });
9452
+ const lut = Array.from({ length: 256 }, function(_, v) { const x = v / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); });
9453
+ const times = Array.from({ length: ${n} }, function(_, i) { return i / ${fps}; });
9454
+ out.series = app.captureFramesAt(times, { capture: function(c) {
9455
+ // Transparent canvas shows the page's CSS background, so composite on it.
9456
+ ctx.fillStyle = out.page || '#ffffff';
9457
+ ctx.fillRect(0, 0, W, H);
9458
+ ctx.drawImage(c, 0, 0, W, H);
9459
+ const px = ctx.getImageData(0, 0, W, H).data;
9460
+ const acc = Array.prototype.reduce.call(px, function(a, v, k) {
9461
+ if (k % 4 !== 3) return a;
9462
+ const p = (k - 3) / 4, x = p % W, y = (p - x) / W;
9463
+ const cell = Math.min(2, Math.floor(y * 3 / H)) * 3 + Math.min(2, Math.floor(x * 3 / W));
9464
+ a.sum[cell] += 0.2126 * lut[px[k - 3]] + 0.7152 * lut[px[k - 2]] + 0.0722 * lut[px[k - 1]];
9465
+ a.n[cell] += 1;
9466
+ return a;
9467
+ }, { sum: [0, 0, 0, 0, 0, 0, 0, 0, 0], n: [0, 0, 0, 0, 0, 0, 0, 0, 0] });
9468
+ return acc.sum.map(function(s, i) { return acc.n[i] ? s / acc.n[i] : 0; });
9469
+ } });
9470
+ }
9471
+ ` : ''} return out;
9472
+ })();`.trim();
9473
+ }
7445
9474
  generateCaptureFrames(input) {
7446
9475
  const timesJson = JSON.stringify(input.times);
7447
9476
  const seed = input.seed !== undefined ? input.seed : 0;
@@ -7453,7 +9482,18 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7453
9482
  return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
7454
9483
  }
7455
9484
  const includeDataUrls = ${includeDataUrls};
7456
- const hashStr = function(s) { let h = 5381; for (let i = 0; i < s.length; i++) { h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; } return h.toString(16); };
9485
+ // THE HASH LOOP RAN ONCE PER CHARACTER OF A DATA URL, inside generated code,
9486
+ // where every loop site is budgeted at 2,000,000 iterations — so any frame
9487
+ // over ~2 MB (three photos, a shader backdrop) died "Loop budget exceeded".
9488
+ // app.hashFrame is the same djb2-xor in engine code, outside the budget, so
9489
+ // hashes stay comparable with builds that only have the fallback.
9490
+ const hashStr = (typeof app.hashFrame === 'function')
9491
+ ? function(s) { return app.hashFrame(s); }
9492
+ // The fallback must not be a for loop either: the budget is per loop SITE
9493
+ // across the whole run, so 50 frames of ~40 KB data URLs exhausted it on a
9494
+ // plain 9-text scene (round 7 X, #7) on studios without hashFrame. A native
9495
+ // reduce is not a generated loop and is not counted; same djb2-xor.
9496
+ : function(s) { return Array.prototype.reduce.call(s, function(h, c) { return (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0; }, 5381).toString(16); };
7457
9497
  const frames = app.captureFramesAt(${timesJson}, {
7458
9498
  seed: ${seed},
7459
9499
  capture: function(c, t, i) {
@@ -7571,6 +9611,47 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7571
9611
  generateMedia(input) {
7572
9612
  const guard = ` const A = (typeof window !== 'undefined') && window.PinePaperAgent;
7573
9613
  if (!A || typeof A.uploadVideo !== 'function') { return { success: false, error: 'window.PinePaperAgent media API unavailable — update FxTool to a media-capable build' }; }`;
9614
+ // The URL to upload from: the staged bytes when the handler put a large
9615
+ // data: URL on window.__ppStage (see pinepaper_media in handlers.ts).
9616
+ //
9617
+ // A data: URL IS DECODED HERE INTO A File, NOT HANDED OVER AS A URL. The
9618
+ // engine's upload fetch()es a string, and production's CSP refuses fetch()
9619
+ // of data: — so every data: upload failed on prod with "Failed to fetch ()"
9620
+ // at every size (a local engine without the CSP accepted small ones, which
9621
+ // is what made it look like a size limit). uploadAudio/uploadVideo take a
9622
+ // File directly. Uint8Array.from with a map, not a for loop: a generated
9623
+ // loop is budgeted per iteration and a few MB of audio would exhaust it.
9624
+ const srcExpr = (url, kind) => `(function(u) {
9625
+ if (u.indexOf('__ppStage:') === 0) {
9626
+ const k = u.slice('__ppStage:'.length);
9627
+ const v = window.__ppStage && window.__ppStage[k];
9628
+ if (!v) throw new Error('the media bytes were staged as ' + k + ' and are not on the page — a bug in the MCP server, not your call.');
9629
+ u = v;
9630
+ }
9631
+ if (u.indexOf('data:') !== 0) return u;
9632
+ const comma = u.indexOf(',');
9633
+ const meta = u.slice(5, comma);
9634
+ const mime = meta.split(';')[0] || '${kind === 'audio' ? 'audio/mpeg' : 'video/mp4'}';
9635
+ const body = u.slice(comma + 1);
9636
+ const bin = /;base64/i.test(meta) ? atob(body) : decodeURIComponent(body);
9637
+ const bytes = Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
9638
+ const ext = (mime.split('/')[1] || '${kind === 'audio' ? 'mp3' : 'mp4'}').replace(/[^a-z0-9]/gi, '');
9639
+ return new File([bytes], '${kind}.' + ext, { type: mime });
9640
+ })(${JSON.stringify(url ?? '')})`;
9641
+ // TWO ID SPACES, AND THE CALLER HOLDS THE WRONG ONE.
9642
+ //
9643
+ // upload answers {id, registryId}; every other tool on this surface speaks
9644
+ // registry ids (item_N), so that is the one callers keep. But remove,
9645
+ // setMediaPlaybackRate and setMediaClip look the id up in the audio/video
9646
+ // layers, which are keyed by MEDIA id — so set_playback_rate with the
9647
+ // registryId returned false, reported as a failure with no reason. Either
9648
+ // form is resolved here; a miss names the id and what was looked for.
9649
+ const resolveMedia = ` const __ref = ${JSON.stringify(input.id ?? '')};
9650
+ const __m = (typeof A.listMedia === 'function')
9651
+ ? A.listMedia().find(function(x) { return x.id === __ref || x.registryId === __ref; })
9652
+ : null;
9653
+ const __mid = __m ? __m.id : __ref;
9654
+ const __miss = { success: false, error: 'no uploaded audio or video matches ' + JSON.stringify(__ref) + ' — pass the id or registryId that upload returned (media list shows both).' };`;
7574
9655
  switch (input.action) {
7575
9656
  case 'upload_video': {
7576
9657
  const opts = JSON.stringify({
@@ -7584,7 +9665,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
7584
9665
  // Upload video from URL
7585
9666
  (async function() {
7586
9667
  ${guard}
7587
- const info = await A.uploadVideo(${JSON.stringify(input.url)}, ${opts});
9668
+ const info = await A.uploadVideo(${srcExpr(input.url, 'video')}, ${opts});
7588
9669
  return { success: true, action: 'upload_video', media: info };
7589
9670
  })();`.trim();
7590
9671
  }
@@ -7599,7 +9680,13 @@ ${guard}
7599
9680
  // Upload audio from URL
7600
9681
  (async function() {
7601
9682
  ${guard}
7602
- const info = await A.uploadAudio(${JSON.stringify(input.url)}, ${opts});
9683
+ const info = await A.uploadAudio(${srcExpr(input.url, 'audio')}, ${opts});${input.volume !== undefined ? `
9684
+ // THE LEVEL IS STORED UNDER ONE KEY AND EXPORTED FROM ANOTHER. The upload
9685
+ // records volume as \`gain\` on the registry entry; the video exporter's mix
9686
+ // reads \`audioGain\`. So a bed uploaded at 0.25 exported at unity (measured:
9687
+ // 0.0 dB change). Written to both until the engine reads one.
9688
+ const __ae = info && info.registryId && app.itemRegistry ? app.itemRegistry.get(info.registryId) : null;
9689
+ if (__ae) { __ae.properties = __ae.properties || {}; __ae.properties.audioGain = ${input.volume}; }` : ''}
7603
9690
  return { success: true, action: 'upload_audio', media: info };
7604
9691
  })();`.trim();
7605
9692
  }
@@ -7615,16 +9702,24 @@ ${guard}
7615
9702
  // Remove media
7616
9703
  (function() {
7617
9704
  ${guard}
7618
- const removed = A.removeMedia(${JSON.stringify(input.id)});
7619
- return { success: removed, action: 'remove', id: ${JSON.stringify(input.id)}, removed: removed };
9705
+ ${resolveMedia}
9706
+ if (!__m) return __miss;
9707
+ const removed = A.removeMedia(__mid);
9708
+ return removed
9709
+ ? { success: true, action: 'remove', id: __mid, registryId: __m.registryId, removed: true }
9710
+ : { success: false, action: 'remove', id: __mid, error: 'the studio did not remove ' + __mid + '.' };
7620
9711
  })();`.trim();
7621
9712
  case 'set_playback_rate':
7622
9713
  return `
7623
9714
  // Set media playback rate
7624
9715
  (function() {
7625
9716
  ${guard}
7626
- const ok = A.setMediaPlaybackRate(${JSON.stringify(input.id)}, ${input.rate});
7627
- return { success: ok, action: 'set_playback_rate', id: ${JSON.stringify(input.id)}, rate: ${input.rate} };
9717
+ ${resolveMedia}
9718
+ if (!__m) return __miss;
9719
+ const ok = A.setMediaPlaybackRate(__mid, ${input.rate});
9720
+ return ok
9721
+ ? { success: true, action: 'set_playback_rate', id: __mid, registryId: __m.registryId, rate: ${input.rate} }
9722
+ : { success: false, action: 'set_playback_rate', id: __mid, error: 'the studio refused rate ${input.rate} for ' + __mid + '.' };
7628
9723
  })();`.trim();
7629
9724
  case 'set_clip':
7630
9725
  return `
@@ -7632,8 +9727,12 @@ ${guard}
7632
9727
  (function() {
7633
9728
  ${guard}
7634
9729
  if (typeof A.setMediaClip !== 'function') { return { success: false, error: 'setMediaClip unavailable — update FxTool' }; }
7635
- const ok = A.setMediaClip(${JSON.stringify(input.id)}, ${input.inPoint}, ${input.outPoint});
7636
- return { success: ok, action: 'set_clip', id: ${JSON.stringify(input.id)}, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} };
9730
+ ${resolveMedia}
9731
+ if (!__m) return __miss;
9732
+ const ok = A.setMediaClip(__mid, ${input.inPoint}, ${input.outPoint});
9733
+ return ok
9734
+ ? { success: true, action: 'set_clip', id: __mid, registryId: __m.registryId, inPoint: ${input.inPoint}, outPoint: ${input.outPoint} }
9735
+ : { success: false, action: 'set_clip', id: __mid, error: 'the studio refused the clip window ${input.inPoint}–${input.outPoint} for ' + __mid + '.' };
7637
9736
  })();`.trim();
7638
9737
  // ── Video-editing actions (v1.6.4) — these live on `app` (PinePaper
7639
9738
  // facades), not the agent media API, so they carry their own guards.
@@ -7645,7 +9744,33 @@ ${guard}
7645
9744
  (function() {
7646
9745
  if (typeof app.setTimeRemap !== 'function') { return { success: false, error: 'app.setTimeRemap unavailable — update FxTool to a time-remap-capable build' }; }
7647
9746
  const r = app.setTimeRemap(${S(input.id)}, ${S(input.remapTrack ?? null)});
7648
- return r && r.ok ? { success: true, action: 'set_time_remap', points: r.points } : { success: false, error: (r && r.error) || 'remap failed' };
9747
+ if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'remap failed' };
9748
+ // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9749
+ // the timeline only for its trimmed window, and past that it is HIDDEN
9750
+ // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9751
+ // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9752
+ // of the curve is a hold. Both answered success before.
9753
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
9754
+ const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9755
+ const __p = (__e && __e.properties) || {};
9756
+ const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
9757
+ const __warnings = [];
9758
+ const __out = typeof __p.outPoint === 'number' ? __p.outPoint : __p.duration;
9759
+ if (__tr.length > 1 && Number.isFinite(__out)) {
9760
+ const __in = typeof __p.inPoint === 'number' ? __p.inPoint : 0;
9761
+ const __start = typeof __p.clipStartTime === 'number' ? __p.clipStartTime : 0;
9762
+ const __end = __start + Math.max(0.001, __out - __in);
9763
+ const __last = __tr[__tr.length - 1].time;
9764
+ if (__last > __end + 0.001) {
9765
+ __warnings.push('the remap runs to ' + __last + ' s but the clip is on the timeline only until ' + __end.toFixed(3) + ' s (start + trimmed length); after that it is hidden, not extended. Lengthen the clip window first.');
9766
+ }
9767
+ const __vals = __tr.map(function(k) { return k.value; });
9768
+ const __hi = Math.max.apply(null, __vals), __lo = Math.min.apply(null, __vals);
9769
+ if (__hi > __out + 0.001 || __lo < __in - 0.001) {
9770
+ __warnings.push('remap source times run ' + __lo.toFixed(3) + '–' + __hi.toFixed(3) + ' s but the clip only has ' + __in.toFixed(3) + '–' + __out.toFixed(3) + ' s; the part outside is clamped, which plays as a hold.');
9771
+ }
9772
+ }
9773
+ return Object.assign({ success: true, action: 'set_time_remap', points: r.points }, __warnings.length ? { warnings: __warnings } : {});
7649
9774
  })();`.trim();
7650
9775
  }
7651
9776
  case 'speed_ramp': {
@@ -7655,7 +9780,33 @@ ${guard}
7655
9780
  (function() {
7656
9781
  if (typeof app.speedRamp !== 'function') { return { success: false, error: 'app.speedRamp unavailable — update FxTool to a time-remap-capable build' }; }
7657
9782
  const r = app.speedRamp(${S(input.id)}, ${S(input.segments)});
7658
- return r && r.ok ? { success: true, action: 'speed_ramp', points: r.points } : { success: false, error: (r && r.error) || 'speed ramp failed' };
9783
+ if (!(r && r.ok)) return { success: false, error: (r && r.error) || 'speed ramp failed' };
9784
+ // WHAT THE REMAP CANNOT DO, READ BACK FROM WHAT WAS STORED. The clip is on
9785
+ // the timeline only for its trimmed window, and past that it is HIDDEN
9786
+ // whatever the curve says — a remap to 6.5 s on a 5 s clip cut to black at
9787
+ // 5.0 s. Source values outside [inPoint, outPoint] are clamped, so that part
9788
+ // of the curve is a hold. Both answered success before.
9789
+ const __rid = typeof app._resolveId === 'function' ? app._resolveId(${S(input.id)}) : ${S(input.id)};
9790
+ const __e = __rid && app.itemRegistry && app.itemRegistry.get(__rid);
9791
+ const __p = (__e && __e.properties) || {};
9792
+ const __tr = Array.isArray(__p.timeRemap) ? __p.timeRemap : [];
9793
+ const __warnings = [];
9794
+ const __out = typeof __p.outPoint === 'number' ? __p.outPoint : __p.duration;
9795
+ if (__tr.length > 1 && Number.isFinite(__out)) {
9796
+ const __in = typeof __p.inPoint === 'number' ? __p.inPoint : 0;
9797
+ const __start = typeof __p.clipStartTime === 'number' ? __p.clipStartTime : 0;
9798
+ const __end = __start + Math.max(0.001, __out - __in);
9799
+ const __last = __tr[__tr.length - 1].time;
9800
+ if (__last > __end + 0.001) {
9801
+ __warnings.push('the remap runs to ' + __last + ' s but the clip is on the timeline only until ' + __end.toFixed(3) + ' s (start + trimmed length); after that it is hidden, not extended. Lengthen the clip window first.');
9802
+ }
9803
+ const __vals = __tr.map(function(k) { return k.value; });
9804
+ const __hi = Math.max.apply(null, __vals), __lo = Math.min.apply(null, __vals);
9805
+ if (__hi > __out + 0.001 || __lo < __in - 0.001) {
9806
+ __warnings.push('remap source times run ' + __lo.toFixed(3) + '–' + __hi.toFixed(3) + ' s but the clip only has ' + __in.toFixed(3) + '–' + __out.toFixed(3) + ' s; the part outside is clamped, which plays as a hold.');
9807
+ }
9808
+ }
9809
+ return Object.assign({ success: true, action: 'speed_ramp', points: r.points }, __warnings.length ? { warnings: __warnings } : {});
7659
9810
  })();`.trim();
7660
9811
  }
7661
9812
  case 'match_cut': {
@@ -7797,9 +9948,19 @@ ${guard}
7797
9948
  // Text: write it as STROKED handwriting — a path, not a glyph
7798
9949
  (function() {
7799
9950
  if (typeof app.createCursiveText !== 'function') { return { success: false, error: 'app.createCursiveText unavailable — update FxTool' }; }
7800
- const item = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
7801
- if (!item) { return { success: false, error: 'the cursive text produced no path' }; }
7802
- return { success: true, itemId: item.data && item.data.id };
9951
+ // createCursiveText answers { items, group, totalWidth, bounds } — not an
9952
+ // item — so reading .data.id off the answer returned no id, every time. The
9953
+ // group is registered only on one of its two branches; the other is
9954
+ // registered here so the caller always gets an addressable id.
9955
+ const r = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
9956
+ const g = r && (r.group || (r.data ? r : null));
9957
+ if (!g) { return { success: false, error: 'the cursive text produced no path' }; }
9958
+ let itemId = g.data && (g.data.id || g.data.registryId);
9959
+ if (!itemId && app.itemRegistry && typeof app.itemRegistry.register === 'function') {
9960
+ itemId = app.itemRegistry.register(g, 'cursiveText', { content: ${S(input.text)} }, 'user');
9961
+ }
9962
+ if (!itemId) { return { success: false, error: 'the cursive text was drawn but not registered — no usable item id.' }; }
9963
+ return { success: true, itemId: itemId, strokes: (r.items || []).length, width: r.totalWidth };
7803
9964
  })();`.trim();
7804
9965
  case 'wrap':
7805
9966
  return `
@@ -8283,6 +10444,25 @@ ${needWorld}
8283
10444
  mesh.rotY = mesh.rotYDegrees * Math.PI / 180;
8284
10445
  delete mesh.rotYDegrees;
8285
10446
  }
10447
+ // A COLOUR, NOT A BLACK MESH (round 9 HH, 5.64). The default mesh
10448
+ // shader reads `uniform vec3 diffuse` (0..1) plus metalness / roughness,
10449
+ // and extrude / lathe passed no uniforms — so every authored mesh
10450
+ // rendered black and there was no parameter to change it. color (hex,
10451
+ // [r,g,b] 0..255 or 0..1) becomes diffuse; with none given it is a
10452
+ // neutral light grey, which lights readably. Explicit uniforms win.
10453
+ {
10454
+ const uniforms = { ...(mesh.uniforms ?? {}) };
10455
+ if (uniforms.diffuse === undefined)
10456
+ uniforms.diffuse = mesh.color !== undefined ? world3dColor(mesh.color) : [0.8, 0.8, 0.8];
10457
+ if (uniforms.metalness === undefined && typeof mesh.metalness === 'number')
10458
+ uniforms.metalness = mesh.metalness;
10459
+ if (uniforms.roughness === undefined)
10460
+ uniforms.roughness = typeof mesh.roughness === 'number' ? mesh.roughness : 0.6;
10461
+ delete mesh.color;
10462
+ delete mesh.metalness;
10463
+ delete mesh.roughness;
10464
+ mesh.uniforms = uniforms;
10465
+ }
8286
10466
  return `
8287
10467
  // World3D: ${input.action} — a canvas path becomes real geometry
8288
10468
  (async function() {
@@ -8784,6 +10964,14 @@ ${guard('exportBVH')}
8784
10964
  // Interchange: one PNG per frame
8785
10965
  (async function() {
8786
10966
  ${guard('exportPNGSequence')}
10967
+ // The studio does not size a sequence to the scene: without duration / fps
10968
+ // it writes its own default (measured 45 frames = 3 s at 15 fps for a 1 s
10969
+ // scene, 29 of them duplicates). Said in the result when that happened.
10970
+ const __pngSeqDefaults = ${JSON.stringify((() => {
10971
+ const o = (input.options ?? {});
10972
+ const missing = ['duration', 'fps'].filter((k) => o[k] === undefined);
10973
+ return missing.length ? { defaultsUsed: missing, note: `${missing.join(' and ')} not given, so the studio's own default length was used — it does not follow the scene (runs have measured 45 and 90 frames). Pass options.duration and options.fps to match the scene.` } : {};
10974
+ })())};
8787
10975
  const out = await app.exportPNGSequence(${opts});
8788
10976
  if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
8789
10977
  // A BLOB SERIALISES TO {}. exporter.export() answers a Blob, and returning it
@@ -8798,7 +10986,7 @@ ${guard('exportPNGSequence')}
8798
10986
  fr.onerror = function() { rej(new Error('could not read the PNG sequence')); };
8799
10987
  fr.readAsDataURL(out);
8800
10988
  });
8801
- return { success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size };
10989
+ return Object.assign({ success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size }, __pngSeqDefaults);
8802
10990
  }
8803
10991
  if (Array.isArray(out)) {
8804
10992
  return { success: true, format: 'png-sequence', frames: out.length,
@@ -8967,6 +11155,51 @@ ${guard('createSound')}
8967
11155
  // setSoundPlacement the separate action calls.
8968
11156
  ${placement ? `if (_sid && app.setSoundPlacement) { app.setSoundPlacement(_sid, ${S(placement)}); }` : ''}
8969
11157
  return { success: true, itemId: _sid, itemType: 'sound'${placement ? `, placement: ${S(placement)}` : ''} };
11158
+ })();`.trim();
11159
+ }
11160
+ case 'sequence': {
11161
+ // ONE CALL FOR A WHOLE BED.
11162
+ //
11163
+ // Every cue was create + set_placement, so a 112-cue music bed cost 112
11164
+ // round trips once visual.startTime landed and 224 before it. Nothing
11165
+ // about the work needs a trip each: the cues are independent, they
11166
+ // differ only in time and spec, and the page can loop.
11167
+ //
11168
+ // Each cue is reported individually. A bed where three cues failed and
11169
+ // 109 worked is not a failure, and it is not a success either — the
11170
+ // caller needs to know WHICH, so placed/failed come back by index.
11171
+ const cues = (input.cues ?? []);
11172
+ return `
11173
+ // Sound: a whole sequence of cues, created and placed in one pass
11174
+ (function() {
11175
+ ${guard('createSound')}
11176
+ const cues = ${S(cues)};
11177
+ const placed = [];
11178
+ const failed = [];
11179
+ for (let i = 0; i < cues.length; i++) {
11180
+ const c = cues[i];
11181
+ try {
11182
+ const spec = c.spec || (c.preset ? { preset: c.preset, note: c.note } : null);
11183
+ if (!spec) { failed.push({ index: i, t: c.t, error: 'neither spec nor preset' }); continue; }
11184
+ const item = app.createSound(spec, c.visual || {});
11185
+ if (!item) { failed.push({ index: i, t: c.t, error: 'the cue produced no waveform path' }); continue; }
11186
+ const id = item.data && item.data.id;
11187
+ if (id && app.setSoundPlacement) {
11188
+ app.setSoundPlacement(id, { startTime: c.t, ...(c.duration ? { duration: c.duration } : {}) });
11189
+ }
11190
+ placed.push({ index: i, itemId: id, t: c.t });
11191
+ } catch (e) {
11192
+ failed.push({ index: i, t: c.t, error: (e && e.message) || 'cue failed' });
11193
+ }
11194
+ }
11195
+ return {
11196
+ success: failed.length === 0,
11197
+ action: 'sequence',
11198
+ placed: placed.length,
11199
+ itemIds: placed.map(function(p) { return p.itemId; }),
11200
+ cues: placed,
11201
+ ...(failed.length ? { failed: failed, error: failed.length + ' of ' + cues.length + ' cues did not sound — see failed[] for which.' } : {}),
11202
+ };
8970
11203
  })();`.trim();
8971
11204
  }
8972
11205
  case 'timbre_from_path':