@pinepaper.studio/mcp-server 1.6.12 → 1.6.13

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 (46) hide show
  1. package/README.md +162 -0
  2. package/dist/agent/export-manager.d.ts.map +1 -1
  3. package/dist/agent/export-manager.js +9 -7
  4. package/dist/agent/export-manager.js.map +1 -1
  5. package/dist/assets/repositories/iconify.d.ts +0 -5
  6. package/dist/assets/repositories/iconify.d.ts.map +1 -1
  7. package/dist/assets/repositories/iconify.js +17 -5
  8. package/dist/assets/repositories/iconify.js.map +1 -1
  9. package/dist/browser/puppeteer-controller.d.ts +16 -0
  10. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  11. package/dist/browser/puppeteer-controller.js +32 -2
  12. package/dist/browser/puppeteer-controller.js.map +1 -1
  13. package/dist/design/design-systems.d.ts +27 -2
  14. package/dist/design/design-systems.d.ts.map +1 -1
  15. package/dist/design/design-systems.js +20 -0
  16. package/dist/design/design-systems.js.map +1 -1
  17. package/dist/tools/definitions.d.ts.map +1 -1
  18. package/dist/tools/definitions.js +36 -5
  19. package/dist/tools/definitions.js.map +1 -1
  20. package/dist/tools/engine-methods.d.ts.map +1 -1
  21. package/dist/tools/engine-methods.js +5 -0
  22. package/dist/tools/engine-methods.js.map +1 -1
  23. package/dist/tools/engine-surface.d.ts +1 -1
  24. package/dist/tools/engine-surface.d.ts.map +1 -1
  25. package/dist/tools/engine-surface.js +16 -11
  26. package/dist/tools/engine-surface.js.map +1 -1
  27. package/dist/tools/handlers.d.ts +13 -2
  28. package/dist/tools/handlers.d.ts.map +1 -1
  29. package/dist/tools/handlers.js +219 -13
  30. package/dist/tools/handlers.js.map +1 -1
  31. package/dist/tools/shape-params.d.ts +2 -2
  32. package/dist/tools/shape-params.js +2 -2
  33. package/dist/tools/stick-vocabulary.d.ts +1 -1
  34. package/dist/tools/stick-vocabulary.js +1 -1
  35. package/dist/tools/toolkits.d.ts.map +1 -1
  36. package/dist/tools/toolkits.js +10 -1
  37. package/dist/tools/toolkits.js.map +1 -1
  38. package/dist/types/code-generator.d.ts.map +1 -1
  39. package/dist/types/code-generator.js +448 -74
  40. package/dist/types/code-generator.js.map +1 -1
  41. package/dist/types/schemas.d.ts +94 -9
  42. package/dist/types/schemas.d.ts.map +1 -1
  43. package/dist/types/schemas.js +14 -5
  44. package/dist/types/schemas.js.map +1 -1
  45. package/manifest.json +1 -1
  46. package/package.json +1 -1
@@ -128,6 +128,27 @@ function generateCreateItemCode(itemType, position, properties) {
128
128
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
129
129
  if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
130
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();
151
+ }
131
152
  // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
132
153
  //
133
154
  // The engine names a bad anchor through console.warn and leaves (x, y) as
@@ -416,6 +437,28 @@ function normalizeKeyframePositions(keyframes) {
416
437
  /**
417
438
  * Template for keyframe animation
418
439
  */
440
+ /**
441
+ * AN UNKNOWN itemId IS A SILENT SUCCESS, ACROSS A WHOLE FAMILY OF TOOLS.
442
+ *
443
+ * `app.addAnimation('c0', …)` on an id that does not exist calls
444
+ * console.warn and returns undefined — and production strips console.warn, so
445
+ * over MCP nothing is said at all. The emitter then returned
446
+ * `{success: true}` because the call did not throw. A pilot animated a
447
+ * connector id that was never registered and got success for every keyframe.
448
+ *
449
+ * Emitted as a shared preamble so the check reads the same everywhere and a
450
+ * new tool taking an itemId can adopt it in one line. It resolves through the
451
+ * registry the way the engine does, and names the id it could not find —
452
+ * "item_7 is not on the canvas" is actionable, silence is not.
453
+ */
454
+ function requireItem(itemId, what) {
455
+ return `
456
+ const _target = (app.getItemById && app.getItemById(${JSON.stringify(itemId)}))
457
+ || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}));
458
+ if (!_target) {
459
+ return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
460
+ }`.trim();
461
+ }
419
462
  function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
420
463
  const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
421
464
  const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
@@ -430,9 +473,11 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
430
473
  opts.timeUnits = timeUnits;
431
474
  return `
432
475
  // Apply keyframe animation to ${itemId}
433
- app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
434
-
435
- ({ success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''} });
476
+ (function() {
477
+ ${requireItem(itemId, 'the animation')}
478
+ 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}` : ''} };
480
+ })();
436
481
  `.trim();
437
482
  }
438
483
  /**
@@ -1199,6 +1244,49 @@ const INLINE_REMOTE_IMAGES = `
1199
1244
  return { svg: svgText, notes: notes };
1200
1245
  }
1201
1246
  `;
1247
+ /**
1248
+ * A HEX COLOUR IN THE 3D WORLD POISONS EVERY LATER EXPORT.
1249
+ *
1250
+ * addWorldObject hands `color` to a WebGL uniform — its own example is
1251
+ * `color: [0.9, 0.3, 0.2]`, three floats in 0..1. Give it '#ef4444' and
1252
+ * uniform3fv throws "cannot be converted to a sequence"; the object is
1253
+ * reported as added, and then EVERY subsequent MP4 export fails, because the
1254
+ * broken uniform is in the scene from then on. Same shape as the tainted
1255
+ * canvas: one bad value, success reported, and the damage surfaces somewhere
1256
+ * else entirely.
1257
+ *
1258
+ * Converted here. A caller writing '#ef4444' is not wrong — it is the colour
1259
+ * spelling every other tool in this package takes — so the emitter absorbs the
1260
+ * difference rather than the caller learning a second convention.
1261
+ */
1262
+ function world3dColor(value) {
1263
+ if (Array.isArray(value)) {
1264
+ // Already a triple. Accept 0-255 too, since that is the other common form.
1265
+ const nums = value.filter((v) => typeof v === 'number');
1266
+ if (nums.length < 3)
1267
+ return value;
1268
+ return nums.slice(0, 3).map((n) => (n > 1 ? n / 255 : n));
1269
+ }
1270
+ if (typeof value !== 'string')
1271
+ return value;
1272
+ const hex = value.trim().replace(/^#/, '');
1273
+ const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
1274
+ if (!/^[0-9a-f]{6}$/i.test(full))
1275
+ return value;
1276
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
1277
+ }
1278
+ /** Recursively convert every `color` field in a world3d payload. */
1279
+ function world3dColors(value) {
1280
+ if (Array.isArray(value))
1281
+ return value.map((v) => world3dColors(v));
1282
+ if (!value || typeof value !== 'object')
1283
+ return value;
1284
+ const out = {};
1285
+ for (const [k, v] of Object.entries(value)) {
1286
+ out[k] = k === 'color' ? world3dColor(v) : world3dColors(v);
1287
+ }
1288
+ return out;
1289
+ }
1202
1290
  export class PinePaperCodeGenerator {
1203
1291
  /**
1204
1292
  * Generate code for creating an item
@@ -2032,7 +2120,25 @@ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
2032
2120
  const connectorId = connector.data?.registryId || connector.id;
2033
2121
  app.historyManager.saveState();
2034
2122
 
2035
- ({ connectorId, sourceItemId: '${sourceItemId}', targetItemId: '${targetItemId}' });
2123
+ // A REQUESTED id IS NOT NECESSARILY THE id YOU GOT.
2124
+ //
2125
+ // Connector itself honours 'config.id || <generated>', but DiagramSystem.connect
2126
+ // rebuilds the config from an allowlist — routing, lineColor, lineWidth,
2127
+ // headStyle, headSize, boltEnabled — and 'id' is not on it. So a caller's id is
2128
+ // dropped before the Connector sees it and a timestamp id is minted instead,
2129
+ // which then makes update_connector / remove_connector say "not found" for the
2130
+ // id the caller chose. Engine-side, raised with fxtool; until it lands, the
2131
+ // mismatch is REPORTED rather than left for a later call to trip over.
2132
+ ({
2133
+ connectorId,
2134
+ sourceItemId: '${sourceItemId}',
2135
+ targetItemId: '${targetItemId}',
2136
+ ...(${JSON.stringify(id ?? null)} && connectorId !== ${JSON.stringify(id ?? null)}
2137
+ ? { requestedId: ${JSON.stringify(id ?? null)},
2138
+ idHonoured: false,
2139
+ note: 'this studio dropped the id you asked for and minted its own — use connectorId above for update_connector and remove_connector, not the one you passed.' }
2140
+ : {}),
2141
+ });
2036
2142
  `.trim();
2037
2143
  }
2038
2144
  /**
@@ -2445,12 +2551,16 @@ throw new Error('Unknown diagram mode action: ${action}');
2445
2551
 
2446
2552
  // Check relations
2447
2553
  if (app.relationRegistry) {
2448
- const relations = app.relationRegistry.getAll ? app.relationRegistry.getAll() : [];
2449
- analysis.hasRelations = relations.length > 0;
2450
- relations.forEach(rel => {
2451
- if (rel.relationType) relationSet.add(rel.relationType);
2452
- });
2554
+ // getAll() has never existed on the registry, so this branch always took
2555
+ // the empty fallback and every analysis reported NO relations. getStats()
2556
+ // is the real accessor and answers both questions directly:
2557
+ // associationsByType is keyed by relation type.
2558
+ const relStats = typeof app.relationRegistry.getStats === 'function'
2559
+ ? app.relationRegistry.getStats()
2560
+ : null;
2561
+ (Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
2453
2562
  analysis.relationTypes = Array.from(relationSet);
2563
+ analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
2454
2564
  if (analysis.hasRelations) analysis.hasAnimations = true;
2455
2565
  }
2456
2566
 
@@ -3158,13 +3268,38 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3158
3268
  if (!params || (params.mode || 'keyframes') !== 'keyframes' || !params.keyframes || !params.keyframes.length) {
3159
3269
  return { success: false, platform, format, framing, error: 'framing: "camera" requires a keyframe-mode camera walkthrough with at least one keyframe.' };
3160
3270
  }
3161
- const firstKf = params.keyframes[0];
3162
- const firstZoom = firstKf && firstKf.zoom > 0 ? firstKf.zoom : 1;
3163
- const canvasEl = document.querySelector('canvas');
3164
- cameraDims = {
3165
- width: Math.round((canvasEl ? canvasEl.width : dimensions.width) / firstZoom),
3166
- height: Math.round((canvasEl ? canvasEl.height : dimensions.height) / firstZoom),
3167
- };
3271
+ // MATCH THE ENGINE'S FRAME EXACTLY, AND DO NOT SECOND-GUESS THE ZOOM.
3272
+ //
3273
+ // This divided canvasEl.width — device pixels — so a camera export came
3274
+ // out 2234x1472 whatever the board was. That is fixed by reading the
3275
+ // artboard, and the chain below is deliberately the same one the engine's
3276
+ // own _exportFrameSize() uses: getCanvasSize(), then app.canvasSize, then
3277
+ // a known frame. Two places computing one number is how they drift.
3278
+ //
3279
+ // The zoom divide is gone too, and that is the part worth explaining.
3280
+ // VideoExporter._frameCropRect returns the WHOLE canvas element whenever a
3281
+ // camera animation exists — the camera owns the view, so it captures
3282
+ // everything the camera frames. Dividing by the first keyframe's zoom
3283
+ // therefore never reframed anything; it only shrank the output, so a
3284
+ // zoom-2 export produced a half-size video of the same content. Asking for
3285
+ // the artboard's own size is both simpler and right.
3286
+ //
3287
+ // NOTE: the engine currently scales that whole-canvas crop into the output
3288
+ // frame without preserving aspect, so the picture is squashed by the ratio
3289
+ // between the workspace canvas and the board (measured: 1.17 landscape,
3290
+ // 0.37 portrait). That is VideoExporter._frameCropRect, not this — raised
3291
+ // with fxtool-f2. Matching frames here means that when it is fixed, these
3292
+ // two cannot disagree about the aspect on top of it.
3293
+ let camBase = null;
3294
+ if (typeof app.getCanvasSize === 'function') {
3295
+ const cs = app.getCanvasSize();
3296
+ if (cs && cs.width > 0) camBase = { width: cs.width, height: cs.height };
3297
+ }
3298
+ if (!camBase && app.canvasSize && app.canvasSize.width > 0) {
3299
+ camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
3300
+ }
3301
+ if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
3302
+ cameraDims = { width: camBase.width, height: camBase.height };
3168
3303
  }
3169
3304
 
3170
3305
  let result = { success: false, platform, format, quality, framing };
@@ -3186,14 +3321,35 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3186
3321
 
3187
3322
  case 'png':
3188
3323
  if (app.exportEngine && app.exportEngine.exportPNG) {
3189
- const dataUrl = await app.exportEngine.exportPNG({ dpi: settings.dpi });
3324
+ // PNG IGNORED THE PLATFORM ENTIRELY.
3325
+ //
3326
+ // exportPNG takes dpi or scale and no dimensions, so passing only
3327
+ // dpi scaled the CANVAS by dpi/96 and the preset was never applied:
3328
+ // platform 'youtube-thumbnail' (1280x720) and platform 'auto' on a
3329
+ // 1920x1080 board both returned 3000x1688, which is 1920 x 150/96.
3330
+ // Third member of the same family as the camera dims and analyze —
3331
+ // a pixel count that came from the rendering scale rather than from
3332
+ // what the caller asked for.
3333
+ //
3334
+ // dimensions is already resolved: the preset for a named platform,
3335
+ // the board itself for auto. Turning it into a scale is the only
3336
+ // lever exportPNG offers, so that is what it gets.
3337
+ const pngBoard = (typeof app.getCanvasSize === 'function' && app.getCanvasSize())
3338
+ || app.canvasSize
3339
+ || { width: dimensions.width, height: dimensions.height };
3340
+ const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
3341
+ const png = await app.exportEngine.exportPNG({ scale: pngScale });
3342
+ const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
3190
3343
  result = {
3191
3344
  success: true,
3192
3345
  platform,
3193
3346
  format: 'png',
3194
3347
  data: dataUrl,
3195
3348
  mimeType: 'image/png',
3196
- size: Math.round(dataUrl.length * 0.75)
3349
+ size: Math.round(String(dataUrl).length * 0.75),
3350
+ dimensions: (png && png.width)
3351
+ ? { width: png.width, height: png.height }
3352
+ : { width: dimensions.width, height: dimensions.height },
3197
3353
  };
3198
3354
  } else {
3199
3355
  const canvas = document.querySelector('canvas');
@@ -3460,12 +3616,16 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3460
3616
 
3461
3617
  // Check relations
3462
3618
  if (app.relationRegistry) {
3463
- const relations = app.relationRegistry.getAll ? app.relationRegistry.getAll() : [];
3464
- analysis.hasRelations = relations.length > 0;
3465
- relations.forEach(rel => {
3466
- if (rel.relationType) relationSet.add(rel.relationType);
3467
- });
3619
+ // getAll() has never existed on the registry, so this branch always took
3620
+ // the empty fallback and every analysis reported NO relations. getStats()
3621
+ // is the real accessor and answers both questions directly:
3622
+ // associationsByType is keyed by relation type.
3623
+ const relStats = typeof app.relationRegistry.getStats === 'function'
3624
+ ? app.relationRegistry.getStats()
3625
+ : null;
3626
+ (Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
3468
3627
  analysis.relationTypes = Array.from(relationSet);
3628
+ analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
3469
3629
  if (analysis.hasRelations) analysis.hasAnimations = true;
3470
3630
  }
3471
3631
 
@@ -3631,7 +3791,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3631
3791
  // interactivity flags have other names entirely. Every one was accepted,
3632
3792
  // JSON-stringified into the call and dropped — a caller who set fillColor
3633
3793
  // got the default grey with no indication why.
3634
- const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, ...passthrough } = raw;
3794
+ const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, options: extraOptions, ...passthrough } = raw;
3635
3795
  // The engine's own style vocabulary — see stylePresets in MapSystem.js.
3636
3796
  const styles = {};
3637
3797
  if (fillColor !== undefined)
@@ -3653,7 +3813,15 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3653
3813
  // learns why their outline did not move.
3654
3814
  if (hoverStroke !== undefined)
3655
3815
  styles.hoverStroke = hoverStroke;
3656
- const engineOptions = { ...passthrough };
3816
+ // `options` was declared by the tool's description and stripped by the
3817
+ // schema, so `{style: 'dark'}` never reached loadMap. It carries the
3818
+ // options not named individually above — style presets, showOcean,
3819
+ // fitBounds, preserve — and is spread FIRST so an explicitly named field
3820
+ // still wins over the same key inside it.
3821
+ const engineOptions = {
3822
+ ...(extraOptions && typeof extraOptions === 'object' ? extraOptions : {}),
3823
+ ...passthrough,
3824
+ };
3657
3825
  if (Object.keys(styles).length > 0)
3658
3826
  engineOptions.styles = styles;
3659
3827
  if (enableHover !== undefined)
@@ -3693,7 +3861,28 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3693
3861
  generateHighlightRegions(input) {
3694
3862
  const validated = HighlightRegionsInputSchema.parse(input);
3695
3863
  const regionIds = JSON.stringify(validated.regionIds);
3696
- const optionsStr = validated.options ? JSON.stringify(validated.options) : '{}';
3864
+ // highlightRegions(ids, style) reads style.fill / .stroke / .strokeWidth.
3865
+ // This passed fillColor / strokeColor / strokeWidth, so the first two were
3866
+ // dropped and every highlight came out the default blue. A caller reaching
3867
+ // for `color` — the obvious name, and the one a pilot actually used — was
3868
+ // rejected by the schema before it got this far; it is accepted now and
3869
+ // means the fill.
3870
+ const ho = (validated.options ?? {});
3871
+ const style = {};
3872
+ const fill = ho.color ?? ho.fillColor ?? ho.fill;
3873
+ if (fill !== undefined)
3874
+ style.fill = fill;
3875
+ const stroke = ho.strokeColor ?? ho.stroke;
3876
+ if (stroke !== undefined)
3877
+ style.stroke = stroke;
3878
+ if (ho.strokeWidth !== undefined)
3879
+ style.strokeWidth = ho.strokeWidth;
3880
+ for (const [k, v] of Object.entries(ho)) {
3881
+ if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
3882
+ style[k] = v;
3883
+ }
3884
+ }
3885
+ const optionsStr = JSON.stringify(style);
3697
3886
  return `
3698
3887
  // Highlight map regions
3699
3888
  (function() {
@@ -3738,7 +3927,45 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3738
3927
  generateApplyDataColors(input) {
3739
3928
  const validated = ApplyDataColorsInputSchema.parse(input);
3740
3929
  const dataStr = JSON.stringify(validated.data);
3741
- const optionsStr = validated.options ? JSON.stringify(validated.options) : '{}';
3930
+ // THE ENGINE TAKES A COLOUR RAMP, NOT THE NAME OF ONE.
3931
+ //
3932
+ // applyDataColors destructures `colorScale = ['#f7fbff', '#08519c']` — two
3933
+ // endpoints it interpolates between — plus `domain` and `legend`. This tool
3934
+ // published a preset NAME ('greens'), which the engine then indexed as an
3935
+ // array: colorScale[0] of a string is 'g', and every region came out
3936
+ // near-black. It also published minValue/maxValue/showLegend, none of which
3937
+ // the engine reads.
3938
+ //
3939
+ // Translated here rather than in the schema: the names are the friendlier
3940
+ // surface and this is ordinary cross-repo vocabulary drift, which is what
3941
+ // the emitter exists to absorb.
3942
+ const RAMPS = {
3943
+ blues: ['#f7fbff', '#08519c'],
3944
+ greens: ['#f7fcf5', '#006d2c'],
3945
+ reds: ['#fff5f0', '#a50f15'],
3946
+ oranges: ['#fff5eb', '#a63603'],
3947
+ purples: ['#fcfbfd', '#54278f'],
3948
+ heat: ['#ffffb2', '#bd0026'],
3949
+ };
3950
+ const o = validated.options ?? {};
3951
+ const engineOptions = {};
3952
+ if (o.colorScale)
3953
+ engineOptions.colorScale = RAMPS[o.colorScale] ?? RAMPS.blues;
3954
+ if (o.minValue !== undefined || o.maxValue !== undefined) {
3955
+ const values = Object.values(validated.data);
3956
+ engineOptions.domain = [
3957
+ o.minValue ?? Math.min(...values),
3958
+ o.maxValue ?? Math.max(...values),
3959
+ ];
3960
+ }
3961
+ if (o.showLegend !== undefined)
3962
+ engineOptions.legend = o.showLegend;
3963
+ for (const [k, v] of Object.entries(o)) {
3964
+ if (!['colorScale', 'minValue', 'maxValue', 'showLegend'].includes(k) && v !== undefined) {
3965
+ engineOptions[k] = v;
3966
+ }
3967
+ }
3968
+ const optionsStr = JSON.stringify(engineOptions);
3742
3969
  return `
3743
3970
  // Apply choropleth data colors
3744
3971
  (function() {
@@ -3805,8 +4032,18 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3805
4032
  }
3806
4033
 
3807
4034
  try {
3808
- app.mapSystem.addLabels({ regions: ${regionsStr}, ...${optionsStr} });
3809
- return { success: true };
4035
+ // addLabels() has never existed. addRegionLabel(regionId, options) is the
4036
+ // engine's own per-region call, and this tool's input is a list of regions,
4037
+ // so the loop IS the mapping — applyCustomLabels takes a
4038
+ // {regionId: text} map instead, which is a different question.
4039
+ const _labelled = [];
4040
+ const _missed = [];
4041
+ for (const _r of ${regionsStr}) {
4042
+ const _item = app.mapSystem.addRegionLabel(_r, ${optionsStr});
4043
+ (_item ? _labelled : _missed).push(_r);
4044
+ }
4045
+ return { success: _labelled.length > 0, labelled: _labelled, count: _labelled.length,
4046
+ ...(_missed.length ? { notLabelled: _missed, note: 'no region on this map matched these ids — check get_highlighted_map_regions or the map you loaded.' } : {}) };
3810
4047
  } catch (error) {
3811
4048
  return { success: false, error: error.message };
3812
4049
  }
@@ -3831,8 +4068,13 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3831
4068
  }
3832
4069
 
3833
4070
  try {
3834
- app.mapSystem.panTo(${validated.lat}, ${validated.lon}${Object.keys(options).length > 0 ? ', ' + JSON.stringify(options) : ''});
3835
- return { success: true, panTo: [${validated.lat}, ${validated.lon}] };
4071
+ // panTo() has never existed on any studio, and there is no lat/lon pan for
4072
+ // a flat map to route this to. Refused by NAME with the calls that do work,
4073
+ // rather than letting the caller take an undefined-is-not-a-function.
4074
+ return { success: false, error: 'pan_map is not supported: the engine has no lat/lon pan. '
4075
+ + 'On a globe, rotateGlobeTo brings a coordinate to the front; on a flat map, zoom to a REGION '
4076
+ + '(zoomToRegion) or call resetView for the whole view. This tool accepted coordinates and did '
4077
+ + 'nothing for as long as it existed.' };
3836
4078
  } catch (error) {
3837
4079
  return { success: false, error: error.message };
3838
4080
  }
@@ -3857,8 +4099,11 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3857
4099
  }
3858
4100
 
3859
4101
  try {
3860
- app.mapSystem.zoomTo(${validated.level}${Object.keys(options).length > 0 ? ', ' + JSON.stringify(options) : ''});
3861
- return { success: true, zoomLevel: ${validated.level} };
4102
+ // zoomTo(level) has never existed. The engine zooms to a REGION, not to a
4103
+ // numeric level, so there is nothing to convert a level into.
4104
+ return { success: false, error: 'zoom_map takes a numeric level and the engine has no such call: it zooms to a REGION. '
4105
+ + 'Use zoomToRegion with a region id, or resetView for the full view. A level was accepted here and did '
4106
+ + 'nothing for as long as this tool existed.' };
3862
4107
  } catch (error) {
3863
4108
  return { success: false, error: error.message };
3864
4109
  }
@@ -3877,8 +4122,15 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3877
4122
  }
3878
4123
 
3879
4124
  try {
3880
- const mapData = app.mapSystem.exportMap();
3881
- return { success: true, ...mapData };
4125
+ // exportMap() has never existed. exportGeoJSON() is the real one, and it
4126
+ // returns the geometry rather than a "configuration" — named accordingly
4127
+ // instead of being spread into a shape the caller cannot rely on.
4128
+ if (typeof app.mapSystem.exportGeoJSON !== 'function') {
4129
+ return { success: false, error: 'this studio cannot export map data — app.mapSystem.exportGeoJSON is unavailable.' };
4130
+ }
4131
+ const geojson = app.mapSystem.exportGeoJSON();
4132
+ const info = typeof app.mapSystem.getMapSourceInfo === 'function' ? app.mapSystem.getMapSourceInfo() : null;
4133
+ return { success: true, geojson, ...(info ? { source: info } : {}) };
3882
4134
  } catch (error) {
3883
4135
  return { success: false, error: error.message };
3884
4136
  }
@@ -3945,7 +4197,16 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3945
4197
  }
3946
4198
 
3947
4199
  try {
3948
- const region = app.mapSystem.getRegionAtPoint({ x: ${validated.x}, y: ${validated.y} });
4200
+ // getRegionAtPoint() has never existed; the engine exposes no hit test.
4201
+ // canvasToGeo turns a point into COORDINATES, which is a different answer,
4202
+ // so it is offered rather than quietly substituted.
4203
+ if (typeof app.mapSystem.canvasToGeo === 'function') {
4204
+ const coord = app.mapSystem.canvasToGeo(${validated.x}, ${validated.y});
4205
+ return { success: false, coordinate: coord,
4206
+ error: 'the engine has no region hit test. That point is at ' + JSON.stringify(coord)
4207
+ + ' — find regions by id with get_highlighted_map_regions or getSelectedRegions.' };
4208
+ }
4209
+ const region = null;
3949
4210
  if (region) {
3950
4211
  return { success: true, regionId: region.id, regionName: region.name, properties: region.properties };
3951
4212
  }
@@ -3999,7 +4260,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
3999
4260
  }
4000
4261
 
4001
4262
  try {
4002
- const result = app.mapSystem.animateWave({
4263
+ const result = app.mapSystem.animateRegionsWave({
4003
4264
  duration: ${validated.duration || 10},
4004
4265
  loop: ${validated.loop !== false},
4005
4266
  colors: ${colorsStr},
@@ -4697,6 +4958,17 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4697
4958
  const keyframesStr = validated.keyframes ? JSON.stringify(validated.keyframes) : 'null';
4698
4959
  const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
4699
4960
  const preset = validated.preset || validated.maskType || 'wipeLeft';
4961
+ // MODE 3 PASSED AN ANIMATION PRESET WHERE A MASK SHAPE GOES.
4962
+ //
4963
+ // applyCustomMask(item, maskType, keyframes, options) wants a SHAPE —
4964
+ // 'rectangle', 'circle', 'star'. With preset + keyframes this handed it
4965
+ // 'iris' or 'wipeLeft', which is an animation name, so the shape was
4966
+ // whatever the engine falls back to and the keyframes drove the wrong
4967
+ // geometry. maskType is the field that carries the shape, and it is only
4968
+ // used as the preset above when no preset was given at all.
4969
+ const customShape = validated.maskType && validated.maskType !== preset
4970
+ ? validated.maskType
4971
+ : 'rectangle';
4700
4972
  // Per-preset options. Custom keyframes are an animated-mask extension —
4701
4973
  // when supplied, fall through to maskingSystem.applyCustomMask instead.
4702
4974
  return `
@@ -4712,7 +4984,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
4712
4984
  const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
4713
4985
  let maskedGroup;
4714
4986
  if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
4715
- maskedGroup = app.maskingSystem.applyCustomMask(item, '${preset}', ${keyframesStr}, mergedOpts);
4987
+ maskedGroup = app.maskingSystem.applyCustomMask(item, '${customShape}', ${keyframesStr}, mergedOpts);
4716
4988
  } else {
4717
4989
  maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
4718
4990
  }
@@ -5016,6 +5288,18 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
5016
5288
  // the network again — so a host that allows fetch but blocks the image
5017
5289
  // load (CORS on <img>, a tainted-canvas refusal) now works too.
5018
5290
  let src = '${url}';
5291
+ // A STAGED KEY, not the bytes. The handler writes anything over ~64KB to
5292
+ // window.__ppStage and passes the key instead, because the governor's
5293
+ // loop-guard transform bails on a code string that large and takes the
5294
+ // run's return value with it. See ExecuteCodeOptions.stage.
5295
+ if (src.indexOf('__ppStage:') === 0) {
5296
+ const _key = src.slice('__ppStage:'.length);
5297
+ const _staged = window.__ppStage && window.__ppStage[_key];
5298
+ if (!_staged) {
5299
+ return { success: false, error: 'the image bytes were staged as ' + _key + ' and are not on the page — this is a bug in the MCP server, not your call.' };
5300
+ }
5301
+ src = _staged;
5302
+ }
5019
5303
  if (!src.startsWith('data:')) {
5020
5304
  let res;
5021
5305
  try {
@@ -5872,7 +6156,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
5872
6156
  // Get history state
5873
6157
  (function() {
5874
6158
  ${guard}
5875
- const state = app.historyManager.getState();
6159
+ const state = app.historyManager.getInfo();
5876
6160
  return { success: true, action: 'get_state', ...state };
5877
6161
  })();`.trim();
5878
6162
  default:
@@ -5949,38 +6233,45 @@ ${resolve}
5949
6233
  }
5950
6234
  }
5951
6235
  generateLasso(input) {
5952
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
6236
+ // LASSO IS A MOUSE TOOL. IT HAS NO HEADLESS FORM.
6237
+ //
6238
+ // These called app.imageTools.activateLasso and .applyLasso, neither of
6239
+ // which exists — imageTools is an ImageToolsManager and the lasso lives on
6240
+ // app.lassoTool. `activate` is real there, but the extraction is driven by
6241
+ // startStroke / continueStroke / endStroke: a freehand path the USER draws.
6242
+ // There is no applyLasso to finish it, so wiring activate alone would leave
6243
+ // a caller in a mode it cannot get out of or complete.
6244
+ //
6245
+ // 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. '
6248
+ + 'To cut a region without a pointer, use pinepaper_extract_object (detects and extracts by description), '
6249
+ + 'or pinepaper_cutout_style / the mask actions on pinepaper_image_tools to shape an existing raster.';
5953
6250
  switch (input.action) {
5954
6251
  case 'activate':
5955
- return `
5956
- // Activate lasso selection
5957
- (async function() {
5958
- ${guard}
5959
- try {
5960
- await app.imageTools.activateLasso(${JSON.stringify(input.itemId || '')});
5961
- return { success: true, action: 'activate', itemId: ${JSON.stringify(input.itemId || '')} };
5962
- } catch (e) {
5963
- return { error: 'Failed to activate lasso: ' + e.message };
5964
- }
5965
- })();`.trim();
5966
6252
  case 'apply':
5967
6253
  return `
5968
- // Apply lasso selection
5969
- (async function() {
5970
- ${guard}
5971
- try {
5972
- const result = await app.imageTools.applyLasso();
5973
- return { success: true, action: 'apply' };
5974
- } catch (e) {
5975
- return { error: 'Failed to apply lasso: ' + e.message };
5976
- }
5977
- })();`.trim();
6254
+ // Lasso — refused, rather than calling into a method that does not exist
6255
+ ({ success: false, action: ${JSON.stringify(input.action)}, error: ${JSON.stringify(why)} });`.trim();
5978
6256
  default:
5979
6257
  return `(function() { return { error: 'Unknown lasso action: ${input.action}' }; })();`;
5980
6258
  }
5981
6259
  }
5982
6260
  generateCutoutStyle(input) {
5983
- const guard = `if (!app.imageTools) return { error: 'ImageTools not available' };`;
6261
+ // THE FACADE WAS THE WRONG ONE ENTIRELY.
6262
+ //
6263
+ // These called app.imageTools.applyCutoutStyle / .getCutoutStyles, and
6264
+ // neither exists anywhere in the engine — imageTools is an
6265
+ // ImageToolsManager and knows nothing about cutout presets. Reported from
6266
+ // production as "apply returns success:true with the error nested inside",
6267
+ // which is what awaiting a call on undefined inside a try looks like.
6268
+ //
6269
+ // The real surface is app.cutoutStyles (a CutoutStylePresets), with
6270
+ // applyPreset(item, name, options) and getPresets(). The parity guard
6271
+ // could not see this: readFacades resolved a class by CONVENTION —
6272
+ // imageTools -> ImageTools, which is not the class's name — so the facade
6273
+ // was never mapped, and an unmapped facade is not checked.
6274
+ const guard = `if (!app.cutoutStyles) return { success: false, error: 'cutout styles are unavailable on this studio (app.cutoutStyles is absent) — update PinePaper Studio.' };`;
5984
6275
  switch (input.action) {
5985
6276
  case 'apply':
5986
6277
  return `
@@ -5988,7 +6279,11 @@ ${resolve}
5988
6279
  (async function() {
5989
6280
  ${guard}
5990
6281
  try {
5991
- const result = await app.imageTools.applyCutoutStyle(${JSON.stringify(input.itemId || '')}, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
6282
+ const target = app.getItemById && app.getItemById(${JSON.stringify(input.itemId || '')});
6283
+ if (!target) {
6284
+ return { success: false, action: 'apply', error: 'no item ' + ${JSON.stringify(input.itemId || '')} + ' — applyPreset takes a live item, so the id has to resolve first.' };
6285
+ }
6286
+ const result = await app.cutoutStyles.applyPreset(target, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
5992
6287
  // An unknown preset returns the very item it was given, unchanged — so a
5993
6288
  // truthy result proves nothing. The engine records the refusal on it.
5994
6289
  const cutRej = result && result.data && result.data.cutoutStyleRejected;
@@ -6005,7 +6300,9 @@ ${resolve}
6005
6300
  // List available cutout styles
6006
6301
  (function() {
6007
6302
  ${guard}
6008
- const styles = app.imageTools.getCutoutStyles();
6303
+ const styles = typeof app.getCutoutStylePresets === 'function'
6304
+ ? app.getCutoutStylePresets()
6305
+ : app.cutoutStyles.getPresets();
6009
6306
  return { success: true, action: 'list', styles };
6010
6307
  })();`.trim();
6011
6308
  default:
@@ -6046,14 +6343,25 @@ ${resolve}
6046
6343
 
6047
6344
  const precomp = app.createPrecomp(items, opts);
6048
6345
  if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
6049
- return { success: true, action: 'create', precompId: precomp?.id || precomp, itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
6346
+ // STRINGIFIED, because the schema declares precompId as a string and the
6347
+ // engine mints a NUMBER. Returning the number meant the id could not be
6348
+ // round-tripped: pass it back to add/remove and zod rejects it before the
6349
+ // call is made.
6350
+ const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
6351
+ return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
6050
6352
  })();`.trim();
6051
6353
  }
6052
6354
  case 'add':
6053
6355
  return `
6054
6356
  // Add item to precomp
6055
6357
  (function() {
6056
- app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
6358
+ // addToPrecomp answers falsy when the precomp or the item is not found, and
6359
+ // this reported success regardless — the same silent-success shape as an
6360
+ // unknown itemId elsewhere.
6361
+ const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
6362
+ if (_ok === false || _ok === null || _ok === undefined) {
6363
+ return { success: false, action: 'add', error: 'nothing was added — check the precompId (create returns it as a string) and the itemId with pinepaper_get_items.' };
6364
+ }
6057
6365
  return { success: true, action: 'add', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
6058
6366
  })();`.trim();
6059
6367
  case 'remove':
@@ -6572,7 +6880,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
6572
6880
  // Trigger interaction action
6573
6881
  (function() {
6574
6882
  ${guard}
6575
- app.interactionSystem.triggerAction('${input.actionType}', ${params});
6883
+ app.interactionSystem.executeAction('${input.actionType}', ${params});
6576
6884
  return { success: true, action: 'trigger_action', actionType: '${input.actionType}' };
6577
6885
  })();`.trim();
6578
6886
  }
@@ -7468,6 +7776,9 @@ ${guard}
7468
7776
  ...(r.note ? { note: r.note } : {}) };
7469
7777
  })();`.trim();
7470
7778
  }
7779
+ // 'list' is the spelling pinepaper_text_effect uses for the same action,
7780
+ // accepted here so moving between the two tools does not dead-end.
7781
+ case 'list':
7471
7782
  case 'list_styles': {
7472
7783
  return `
7473
7784
  // Text styles + palettes + font axes — the picker surface
@@ -7703,9 +8014,42 @@ ${guard}
7703
8014
  // r.items are live Paper objects — NOT serializable, deliberately dropped.
7704
8015
  // The source item id is GONE unless keepSource was set, so r.ids is now the
7705
8016
  // caller's entire handle set; returning it is what makes the tool usable.
8017
+ // EVERY EFFECT PLAYS FROM t=0, so in a multi-shot video they all fire on the
8018
+ // opening frame and only the first title reads. The engine has no start
8019
+ // delay, so the keyframes it just wrote are shifted here — the same thing a
8020
+ // caller was doing by hand through execute_custom_code.
8021
+ //
8022
+ // Shifted, then held: without an explicit invisible keyframe at 0 the
8023
+ // characters sit in their FIRST pose until the effect starts, which for most
8024
+ // of the 37 is a scatter that looks like a bug. hideAfter closes the other
8025
+ // end, because they otherwise survive to the end of the clip.
8026
+ const _startAt = ${S(input.startAt ?? 0)};
8027
+ const _hideAfter = ${S(input.hideAfter ?? null)};
8028
+ let _shifted = 0;
8029
+ if ((_startAt > 0 || _hideAfter) && Array.isArray(r.ids)) {
8030
+ for (const _id of r.ids) {
8031
+ const _it = app.getItemById && app.getItemById(_id);
8032
+ const _kfs = _it && _it.data && _it.data.keyframes;
8033
+ if (!Array.isArray(_kfs) || _kfs.length === 0) continue;
8034
+ if (_startAt > 0) {
8035
+ for (const _k of _kfs) _k.time += _startAt;
8036
+ _kfs.unshift({ time: 0, properties: { opacity: 0 }, easing: 'linear' });
8037
+ _kfs.splice(1, 0, { time: Math.max(0, _startAt - 0.001), properties: { opacity: 0 }, easing: 'linear' });
8038
+ }
8039
+ if (_hideAfter) {
8040
+ _kfs.push({ time: Math.max(0, _hideAfter - 0.001), properties: { opacity: 1 }, easing: 'linear' });
8041
+ _kfs.push({ time: _hideAfter, properties: { opacity: 0 }, easing: 'linear' });
8042
+ }
8043
+ _kfs.sort((a, b) => a.time - b.time);
8044
+ _shifted++;
8045
+ }
8046
+ }
7706
8047
  return {
7707
8048
  success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
7708
- duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
8049
+ duration: (r.duration || 0) + _startAt, sourceRemoved: ${S(input.keepSource !== true)},
8050
+ ...(_startAt > 0 ? { startAt: _startAt } : {}),
8051
+ ...(_hideAfter ? { hideAfter: _hideAfter } : {}),
8052
+ ...((_startAt > 0 || _hideAfter) ? { retimed: _shifted } : {}),
7709
8053
  };
7710
8054
  })();`.trim();
7711
8055
  }
@@ -7828,7 +8172,7 @@ ${guard}
7828
8172
  // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
7829
8173
  (async function() {
7830
8174
  if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
7831
- await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
8175
+ await app.createWorld3D(${S(world3dColors(input.spec ?? 'forest'))}, ${opts});
7832
8176
  // describe() is the world's own parameter schema — return the preset list +
7833
8177
  // top-level keys so the agent knows what configure can touch, without the
7834
8178
  // full multi-KB schema on every create.
@@ -7849,7 +8193,7 @@ ${guard}
7849
8193
  (function() {
7850
8194
  if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
7851
8195
  ${needWorld}
7852
- const r = app.configureWorld3D(${S(input.patch)});
8196
+ const r = app.configureWorld3D(${S(world3dColors(input.patch))});
7853
8197
  // The validator names the right key on a wrong one — forward it verbatim.
7854
8198
  return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
7855
8199
  })();`.trim();
@@ -7905,7 +8249,7 @@ ${needWorld}
7905
8249
  // World3D: place an object (y defaults to sitting on the terrain)
7906
8250
  (function() {
7907
8251
  ${needWorld}
7908
- const id = app.addWorldObject(${S(input.object)});
8252
+ const id = app.addWorldObject(${S(world3dColors(input.object))});
7909
8253
  return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
7910
8254
  })();`.trim();
7911
8255
  case 'remove_object':
@@ -8442,6 +8786,24 @@ ${guard('exportBVH')}
8442
8786
  ${guard('exportPNGSequence')}
8443
8787
  const out = await app.exportPNGSequence(${opts});
8444
8788
  if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
8789
+ // A BLOB SERIALISES TO {}. exporter.export() answers a Blob, and returning it
8790
+ // raw produced a success whose result was an empty object — which reads as
8791
+ // "it worked and gave me nothing" rather than "the bytes could not cross
8792
+ // the bridge". Structured clone drops a Blob's
8793
+ // contents; only a string survives.
8794
+ if (typeof Blob !== 'undefined' && out instanceof Blob) {
8795
+ const dataUrl = await new Promise(function(res, rej) {
8796
+ const fr = new FileReader();
8797
+ fr.onload = function() { res(fr.result); };
8798
+ fr.onerror = function() { rej(new Error('could not read the PNG sequence')); };
8799
+ fr.readAsDataURL(out);
8800
+ });
8801
+ return { success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size };
8802
+ }
8803
+ if (Array.isArray(out)) {
8804
+ return { success: true, format: 'png-sequence', frames: out.length,
8805
+ note: 'the frames stayed in the page — pass download: true to write them out.' };
8806
+ }
8445
8807
  return { success: true, format: 'png-sequence', result: out };
8446
8808
  })();`.trim();
8447
8809
  }
@@ -8586,15 +8948,27 @@ ${guard('renderSoundtrackWav')}
8586
8948
  };
8587
8949
  })();`.trim();
8588
8950
  }
8589
- case 'create':
8951
+ case 'create': {
8952
+ const v = (input.visual ?? {});
8953
+ const { startTime, duration, ...visualOnly } = v;
8954
+ const placement = (startTime !== undefined || duration !== undefined)
8955
+ ? { ...(startTime !== undefined ? { startTime } : {}), ...(duration !== undefined ? { duration } : {}) }
8956
+ : null;
8590
8957
  return `
8591
8958
  // Sound: draw it AS a waveform path — the item and the sound are one thing
8592
8959
  (function() {
8593
8960
  ${guard('createSound')}
8594
- const item = app.createSound(${S(input.spec ?? {})}, ${S(input.visual ?? {})});
8961
+ const item = app.createSound(${S(input.spec ?? {})}, ${S(visualOnly)});
8595
8962
  if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
8596
- return { success: true, itemId: item.data && item.data.id, itemType: 'sound' };
8963
+ const _sid = item.data && item.data.id;
8964
+ // ONE CALL PER CUE, NOT TWO. startTime/duration were stripped from visual,
8965
+ // so placing a cue meant create + set_placement every time — a pilot made
8966
+ // 160 calls where 80 would do. Applied here when given, using the same
8967
+ // setSoundPlacement the separate action calls.
8968
+ ${placement ? `if (_sid && app.setSoundPlacement) { app.setSoundPlacement(_sid, ${S(placement)}); }` : ''}
8969
+ return { success: true, itemId: _sid, itemType: 'sound'${placement ? `, placement: ${S(placement)}` : ''} };
8597
8970
  })();`.trim();
8971
+ }
8598
8972
  case 'timbre_from_path':
8599
8973
  return `
8600
8974
  // Sound: read a drawn path's shape as harmonic content