@pinepaper.studio/mcp-server 1.6.8 → 1.6.10

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 (62) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +207 -7
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/agent/export-manager.d.ts.map +1 -1
  11. package/dist/agent/export-manager.js +5 -3
  12. package/dist/agent/export-manager.js.map +1 -1
  13. package/dist/browser/index.d.ts +1 -1
  14. package/dist/browser/index.d.ts.map +1 -1
  15. package/dist/browser/index.js +1 -1
  16. package/dist/browser/index.js.map +1 -1
  17. package/dist/browser/puppeteer-controller.d.ts +86 -2
  18. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  19. package/dist/browser/puppeteer-controller.js +271 -63
  20. package/dist/browser/puppeteer-controller.js.map +1 -1
  21. package/dist/cli.js +12 -0
  22. package/dist/cli.js.map +1 -1
  23. package/dist/design/design-systems.d.ts +105 -7
  24. package/dist/design/design-systems.d.ts.map +1 -1
  25. package/dist/design/design-systems.js +210 -16
  26. package/dist/design/design-systems.js.map +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/tools/definitions.d.ts.map +1 -1
  31. package/dist/tools/definitions.js +94 -6
  32. package/dist/tools/definitions.js.map +1 -1
  33. package/dist/tools/engine-methods.d.ts.map +1 -1
  34. package/dist/tools/engine-methods.js +2 -0
  35. package/dist/tools/engine-methods.js.map +1 -1
  36. package/dist/tools/engine-surface.d.ts +30 -0
  37. package/dist/tools/engine-surface.d.ts.map +1 -0
  38. package/dist/tools/engine-surface.js +1188 -0
  39. package/dist/tools/engine-surface.js.map +1 -0
  40. package/dist/tools/handlers/font.d.ts.map +1 -1
  41. package/dist/tools/handlers/font.js +77 -10
  42. package/dist/tools/handlers/font.js.map +1 -1
  43. package/dist/tools/handlers.d.ts +16 -0
  44. package/dist/tools/handlers.d.ts.map +1 -1
  45. package/dist/tools/handlers.js +329 -12
  46. package/dist/tools/handlers.js.map +1 -1
  47. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  48. package/dist/tools/minimal-descriptions.js +2 -1
  49. package/dist/tools/minimal-descriptions.js.map +1 -1
  50. package/dist/tools/toolkits.d.ts.map +1 -1
  51. package/dist/tools/toolkits.js +1 -0
  52. package/dist/tools/toolkits.js.map +1 -1
  53. package/dist/types/code-generator.d.ts +10 -0
  54. package/dist/types/code-generator.d.ts.map +1 -1
  55. package/dist/types/code-generator.js +437 -87
  56. package/dist/types/code-generator.js.map +1 -1
  57. package/dist/types/schemas.d.ts +119 -74
  58. package/dist/types/schemas.d.ts.map +1 -1
  59. package/dist/types/schemas.js +62 -6
  60. package/dist/types/schemas.js.map +1 -1
  61. package/manifest.json +5 -1
  62. package/package.json +5 -2
@@ -886,19 +886,36 @@ function generateBatchModifyCode(modifications) {
886
886
  return `
887
887
  // Batch modify ${modifications.length} items
888
888
  const modifications = ${modsJson};
889
+ // TWO BUGS, ONE CALL, AND BOTH FAILED LOUDLY IN THE WRONG PLACE.
890
+ //
891
+ // 1. The engine takes { item, params } with a LIVE ITEM, or { itemId, params }
892
+ // with a registry id. This put the id STRING into the item field, which is
893
+ // truthy — so the engine's own id lookup never ran, the string was treated
894
+ // as an item, and Object.assign got a string: "Cannot convert undefined or
895
+ // null to object", from a call that named the id correctly.
896
+ // 2. batchModify returns a COUNT, not an array. Calling .map on a number
897
+ // is the second reported error, and it fires even when the modify worked.
889
898
  const results = app.batchModify(modifications.map(mod => ({
890
- item: mod.itemId,
899
+ itemId: mod.itemId,
891
900
  params: mod.params
892
901
  })));
893
902
 
894
- // Return modification results
895
- const formatted = results.map(r => ({
896
- itemId: r.item?.data?.registryId || r.itemId,
897
- success: r.success,
898
- error: r.error || null
899
- }));
903
+ // The engine records what it could not do rather than throwing — read it,
904
+ // instead of reporting a clean success over a half-applied batch.
905
+ //
906
+ // Read defensively (|| []): the property arrived with the engine-side
907
+ // batchModify fix, so an older studio reports a count and no skip list rather
908
+ // than failing. The batch still works; only the explanation is thinner.
909
+ const skipped = app.lastBatchModifySkipped || [];
910
+ const count = typeof results === 'number' ? results : (Array.isArray(results) ? results.length : 0);
900
911
 
901
- ({ success: formatted.every(r => r.success), results: formatted, count: formatted.length });
912
+ ({
913
+ success: skipped.length === 0 && count === modifications.length,
914
+ count,
915
+ requested: modifications.length,
916
+ skipped: skipped.map(s => ({ itemId: s.itemId, reason: s.reason })),
917
+ ...(skipped.length ? { error: 'batch_modify changed ' + count + ' of ' + modifications.length + ' items; skipped: ' + skipped.map(s => s.itemId + ' (' + s.reason + ')').join(', ') } : {}),
918
+ });
902
919
  `.trim();
903
920
  }
904
921
  /**
@@ -972,6 +989,28 @@ export function canvasPresetFor(platform) {
972
989
  * inside the encode.
973
990
  */
974
991
  export const INLINE_MAX_BYTES = 96 * 1024 * 1024;
992
+ /**
993
+ * Wait for the export subsystem before reading it.
994
+ *
995
+ * `app.exportEngine` is a LAZY getter over a code-split chunk: it returns
996
+ * undefined until `ensureHeavyModules()` has run, which the engine idle-prefetches
997
+ * roughly 1.2s after boot. An agent connecting and exporting immediately —
998
+ * which is the whole shape of an automated session — beats that prefetch, so
999
+ * every `app.exportEngine.*` read below found undefined and the generated code
1000
+ * reported the studio as too old to export. The build was fine; the chunk had
1001
+ * not landed yet. Awaiting it is the difference between a check and a race.
1002
+ *
1003
+ * The guards that follow this are left exactly as they are: after the await,
1004
+ * an absent exportEngine really does mean a studio that cannot export.
1005
+ *
1006
+ * Emitted only into the two doors an agent can reach cold. `readExport` and
1007
+ * `releaseExport` are only reachable after an export has already succeeded, by
1008
+ * which point the chunk is resident by construction.
1009
+ */
1010
+ const ENSURE_EXPORT_ENGINE = `
1011
+ if (!app.exportEngine && typeof app.ensureHeavyModules === 'function') {
1012
+ try { await app.ensureHeavyModules(); } catch (_) { /* the guards below still speak */ }
1013
+ }`;
975
1014
  export class PinePaperCodeGenerator {
976
1015
  /**
977
1016
  * Generate code for creating an item
@@ -1233,12 +1272,41 @@ throw new Error('Either svgString or url must be provided');
1233
1272
  if (!app.importMermaid) return { success: false, error: 'importMermaid not available — diagramSystem missing' };
1234
1273
  const mermaidText = \`${escaped}\`;
1235
1274
  const result = app.importMermaid(mermaidText, ${optsJson});
1275
+
1276
+ // NEVER RETURN THE RAW NODES. They are Paper.js items, and a Paper item
1277
+ // refers to its project, which refers back — JSON.stringify dies on the cycle
1278
+ // with "property '_scope' closes the circle", and the tool reported "Failed
1279
+ // to execute code in browser" for an import that had worked. Summarised to
1280
+ // ids, labels and bounds, which is what a caller can act on anyway.
1281
+ const safeNode = (n) => {
1282
+ const item = n && (n.item || n);
1283
+ const b = item && item.bounds;
1284
+ return {
1285
+ id: (item && item.data && item.data.registryId) || (n && n.id) || null,
1286
+ label: (n && (n.label ?? n.text)) ?? null,
1287
+ bounds: b ? { x: Math.round(b.x), y: Math.round(b.y), width: Math.round(b.width), height: Math.round(b.height) } : null,
1288
+ };
1289
+ };
1290
+ const nodes = (result.nodes || []).map(safeNode);
1291
+ const edges = (result.edges || []).map((e) => ({ from: e?.from ?? e?.source ?? null, to: e?.to ?? e?.target ?? null, label: e?.label ?? null }));
1292
+
1293
+ // A zero-item import is NOT a success. The engine reported success:true with
1294
+ // itemsCreated:0 and the tool passed it on, so a diagram that drew nothing
1295
+ // read as a diagram that drew.
1296
+ if (!nodes.length) {
1297
+ return {
1298
+ success: false,
1299
+ error: 'the mermaid import created no items. The text parsed but produced nothing to draw — check the diagram type is one this build supports, and that the body is not empty.',
1300
+ nodeCount: 0, edgeCount: edges.length, errors: result.errors || [],
1301
+ };
1302
+ }
1303
+
1236
1304
  return {
1237
1305
  success: !!result.success,
1238
- nodeCount: result.nodes ? result.nodes.length : 0,
1239
- edgeCount: result.edges ? result.edges.length : 0,
1240
- nodes: result.nodes || [],
1241
- edges: result.edges || [],
1306
+ nodeCount: nodes.length,
1307
+ edgeCount: edges.length,
1308
+ nodes,
1309
+ edges,
1242
1310
  errors: result.errors || [],
1243
1311
  };
1244
1312
  })();
@@ -1553,8 +1621,11 @@ bg.sendToBack();
1553
1621
  if (backgroundGenerator) {
1554
1622
  codeParts.push(`
1555
1623
  // Execute background generator
1556
- if (typeof app.generators !== 'undefined' && app.generators['${backgroundGenerator}']) {
1557
- app.generators['${backgroundGenerator}']();
1624
+ const _gen = app.generatorRegistry?.get('${backgroundGenerator}');
1625
+ if (typeof _gen === 'function') {
1626
+ _gen();
1627
+ } else if (_gen && typeof _gen.generate === 'function') {
1628
+ _gen.generate();
1558
1629
  }
1559
1630
  `);
1560
1631
  }
@@ -1691,8 +1762,9 @@ results;
1691
1762
  const labelStr = label ? JSON.stringify(label) : 'null';
1692
1763
  return `
1693
1764
  // Create diagram shape: ${shapeType}
1765
+ // POSITIONAL: createShape(shapeType, config). The type was being passed
1766
+ // INSIDE the config object, where nothing reads it.
1694
1767
  const shapeConfig = {
1695
- shapeType: '${shapeType}',
1696
1768
  position: { x: ${position.x}, y: ${position.y} },
1697
1769
  width: ${widthVal},
1698
1770
  height: ${heightVal},
@@ -1700,7 +1772,8 @@ const shapeConfig = {
1700
1772
  style: ${styleStr}
1701
1773
  };
1702
1774
 
1703
- const shape = app.diagramManager.createShape(shapeConfig);
1775
+ const shape = app.diagramSystem.createShape('${shapeType}', shapeConfig);
1776
+ if (!shape) throw new Error('createShape returned nothing for shapeType "${shapeType}" — call pinepaper_get_diagram_shapes for the registered types.');
1704
1777
  const itemId = shape.data?.registryId || shape.id;
1705
1778
  app.historyManager.saveState();
1706
1779
 
@@ -1744,7 +1817,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1744
1817
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1745
1818
 
1746
1819
  const config = ${JSON.stringify(config, null, 2)};
1747
- const connector = app.diagramManager.connect(sourceItem, targetItem, config);
1820
+ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
1748
1821
  const connectorId = connector.data?.registryId || connector.id;
1749
1822
  app.historyManager.saveState();
1750
1823
 
@@ -1772,7 +1845,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1772
1845
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1773
1846
 
1774
1847
  const config = ${configStr};
1775
- const connector = app.diagramManager.connectPorts(
1848
+ const connector = app.diagramSystem.connectPorts(
1776
1849
  sourceItem, '${sourcePort}',
1777
1850
  targetItem, '${targetPort}',
1778
1851
  config
@@ -1789,21 +1862,31 @@ app.historyManager.saveState();
1789
1862
  generateAddPorts(input) {
1790
1863
  const validated = AddPortsInputSchema.parse(input);
1791
1864
  const { itemId, portType, ports, count } = validated;
1792
- const portsStr = ports ? JSON.stringify(ports, null, 2) : 'undefined';
1793
- const countVal = count !== undefined ? count : 'undefined';
1865
+ // THE SCHEMA PROMISES MORE THAN THE ENGINE HAS. `addPorts(item, type)` takes
1866
+ // a type string and lays out that type's port set; there is no per-port
1867
+ // placement and no count. The old emitter passed {portType, ports, count}
1868
+ // as the second argument, which the engine tried to iterate — "t.forEach is
1869
+ // not a function" — so every call failed and the two extra fields looked
1870
+ // like they might have worked. Refused by name instead of dropped.
1871
+ if (ports !== undefined || count !== undefined) {
1872
+ const named = [ports !== undefined && 'ports', count !== undefined && 'count'].filter(Boolean).join(' and ');
1873
+ throw new Error(`add_ports cannot honour ${named}: this engine lays out a whole port SET by type `
1874
+ + `(portType '${portType}'), with no per-port placement or count. Drop ${named}, or place `
1875
+ + 'individual connection points as items and join them with pinepaper_connect.');
1876
+ }
1794
1877
  return `
1795
1878
  // Add ports to item: ${itemId}
1796
1879
  const item = app.getItemById('${itemId}');
1797
1880
  if (!item) throw new Error('Item not found: ${itemId}');
1798
1881
 
1799
- const result = app.diagramManager.addPorts(item, {
1800
- portType: '${portType}',
1801
- ports: ${portsStr},
1802
- count: ${countVal}
1803
- });
1882
+ // The second argument is a TYPE STRING, not a config object. Passing an
1883
+ // object made the engine iterate it as a list — "t.forEach is not a function".
1884
+ const result = app.diagramSystem.addPorts(item, '${portType}');
1804
1885
  app.historyManager.saveState();
1805
1886
 
1806
- ({ itemId: '${itemId}', portsAdded: result.portsAdded || 0, portType: '${portType}' });
1887
+ // addPorts returns the ports it made; older builds returned a count object.
1888
+ const _added = Array.isArray(result) ? result.length : (result?.portsAdded ?? 0);
1889
+ ({ itemId: '${itemId}', portsAdded: _added, portType: '${portType}' });
1807
1890
  `.trim();
1808
1891
  }
1809
1892
  /**
@@ -1816,13 +1899,26 @@ app.historyManager.saveState();
1816
1899
  const optionsStr = options ? JSON.stringify(options, null, 2) : '{}';
1817
1900
  return `
1818
1901
  // Apply auto-layout: ${layoutType}
1902
+ // Wrapped: applyLayout is async, and a bare top-level await only works on the
1903
+ // governed path. A paren-led IIFE also keeps the trailing-expression capture.
1904
+ (async function() {
1819
1905
  const itemIds = ${itemIdsStr};
1820
1906
  const options = ${optionsStr};
1821
1907
 
1822
- const result = app.diagramManager.autoLayout('${layoutType}', itemIds, options);
1908
+ // applyLayout(items, type, options) — ITEMS first, and it takes live items
1909
+ // rather than ids. It is async, and it resolves to undefined: reading
1910
+ // result.itemsAffected off that throws, which is how a layout that worked
1911
+ // reported as a crash.
1912
+ const items = itemIds
1913
+ ? itemIds.map((id) => app.getItemById(id)).filter(Boolean)
1914
+ : (app.itemRegistry?.getAll?.() ?? []);
1915
+ if (!items.length) throw new Error('auto_layout found no items to lay out' + (itemIds ? ' for the given itemIds' : ' on the canvas'));
1916
+
1917
+ await app.diagramSystem.applyLayout(items, '${layoutType}', options);
1823
1918
  app.historyManager.saveState();
1824
1919
 
1825
- ({ layoutType: '${layoutType}', itemsAffected: result.itemsAffected || 0, success: result.success });
1920
+ return { layoutType: '${layoutType}', itemsAffected: items.length, success: true };
1921
+ })();
1826
1922
  `.trim();
1827
1923
  }
1828
1924
  /**
@@ -1835,7 +1931,10 @@ app.historyManager.saveState();
1835
1931
  return `
1836
1932
  // Get available diagram shapes
1837
1933
  const category = ${categoryFilter};
1838
- const shapes = app.diagramManager.getAvailableShapes(category);
1934
+ // The library is the registry; diagramSystem has no getAvailableShapes.
1935
+ const _lib = app.diagramSystem?.shapeLibrary;
1936
+ if (!_lib) throw new Error('app.diagramSystem.shapeLibrary unavailable — update FxTool');
1937
+ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
1839
1938
 
1840
1939
  ({ shapes, count: shapes.length, category: ${categoryFilter} || 'all' });
1841
1940
  `.trim();
@@ -1863,10 +1962,44 @@ const connector = app.getItemById('${connectorId}');
1863
1962
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1864
1963
 
1865
1964
  const updates = ${updatesStr};
1866
- app.diagramManager.updateConnector(connector, updates);
1867
- app.historyManager.saveState();
1868
1965
 
1869
- ({ connectorId: '${connectorId}', updated: true });
1966
+ // THERE IS NO updateConnector. ConnectorManager publishes removeConnector,
1967
+ // findConnector and updateSelectedStyle — nothing that edits one connector's
1968
+ // properties in place. The old emitter called app.diagramSystem.updateConnector
1969
+ // and reported updated:true, so a caller changing a connector's colour got a
1970
+ // success and an unchanged connector.
1971
+ //
1972
+ // Applied directly where the property is a plain Paper.js one, and refused by
1973
+ // name where it is not, rather than claiming a change that did not happen.
1974
+ // The engine gained getConnector(id) / updateConnector(id, style), which is
1975
+ // the call this tool always wanted: addressable by the id a connector is
1976
+ // created and serialised with. Guarded, because a studio predating that still
1977
+ // has to do something sensible rather than throw.
1978
+ if (typeof app.diagramSystem?.updateConnector === 'function') {
1979
+ const res = app.diagramSystem.updateConnector('${connectorId}', updates);
1980
+ if (res && res.ok === false) { return { success: false, error: res.reason || 'the connector could not be updated' }; }
1981
+ app.historyManager.saveState();
1982
+ return { connectorId: '${connectorId}', updated: true, applied: Object.keys(updates) };
1983
+ }
1984
+
1985
+ const _applied = [];
1986
+ const _unsupported = [];
1987
+ for (const [k, v] of Object.entries(updates)) {
1988
+ if (k === 'lineColor' && connector.strokeColor !== undefined) { connector.strokeColor = v; _applied.push(k); }
1989
+ else if (k === 'lineWidth' && connector.strokeWidth !== undefined) { connector.strokeWidth = v; _applied.push(k); }
1990
+ else _unsupported.push(k);
1991
+ }
1992
+ if (_applied.length) { app.historyManager.saveState(); app._scheduleRepaint?.(); }
1993
+ if (_unsupported.length && !_applied.length) {
1994
+ throw new Error(
1995
+ 'this build cannot update ' + _unsupported.join(', ') + ' on an existing connector — '
1996
+ + 'this build has no per-connector update — app.diagramSystem.updateConnector arrived later. '
1997
+ + 'Update the studio for it. Removing and recreating the connector works, but mints a NEW id '
1998
+ + 'and orphans anything holding the old one, so it is a last resort rather than the fix.'
1999
+ );
2000
+ }
2001
+
2002
+ ({ connectorId: '${connectorId}', updated: _applied.length > 0, applied: _applied, unsupported: _unsupported });
1870
2003
  `.trim();
1871
2004
  }
1872
2005
  /**
@@ -1880,7 +2013,7 @@ app.historyManager.saveState();
1880
2013
  const connector = app.getItemById('${connectorId}');
1881
2014
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1882
2015
 
1883
- app.diagramManager.removeConnector(connector);
2016
+ app.diagramSystem.connectorManager.removeConnector(connector);
1884
2017
  app.historyManager.saveState();
1885
2018
 
1886
2019
  ({ connectorId: '${connectorId}', removed: true });
@@ -1896,19 +2029,19 @@ app.historyManager.saveState();
1896
2029
  case 'activate':
1897
2030
  return `
1898
2031
  // Activate diagram mode
1899
- app.diagramManager.activate();
2032
+ app.diagramSystem.activate();
1900
2033
  ({ action: 'activate', active: true });
1901
2034
  `.trim();
1902
2035
  case 'deactivate':
1903
2036
  return `
1904
2037
  // Deactivate diagram mode
1905
- app.diagramManager.deactivate();
2038
+ app.diagramSystem.deactivate();
1906
2039
  ({ action: 'deactivate', active: false });
1907
2040
  `.trim();
1908
2041
  case 'toggle':
1909
2042
  return `
1910
2043
  // Toggle diagram mode
1911
- const isActive = app.diagramManager.toggle();
2044
+ const isActive = app.diagramSystem.toggle();
1912
2045
  ({ action: 'toggle', active: isActive });
1913
2046
  `.trim();
1914
2047
  case 'setMode':
@@ -1916,7 +2049,7 @@ const isActive = app.diagramManager.toggle();
1916
2049
  const shapeStr = shapeType ? `, '${shapeType}'` : '';
1917
2050
  return `
1918
2051
  // Set diagram tool mode
1919
- app.diagramManager.setMode(${modeStr}${shapeStr});
2052
+ app.diagramSystem.setMode(${modeStr}${shapeStr});
1920
2053
  ({ action: 'setMode', mode: ${modeStr}${shapeType ? `, shapeType: '${shapeType}'` : ''} });
1921
2054
  `.trim();
1922
2055
  default:
@@ -2075,7 +2208,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2075
2208
 
2076
2209
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2077
2210
  // Check timeline for active animations
2078
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
2211
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2079
2212
  analysis.hasAnimations = true;
2080
2213
  animationSet.add('timeline');
2081
2214
  }
@@ -2204,6 +2337,17 @@ throw new Error('Unknown diagram mode action: ${action}');
2204
2337
  let code = `
2205
2338
  // Batch execute ${operations.length} operations
2206
2339
  (async function() {
2340
+ // An op's own verdict. Deliberately narrow, matching the server-side rule:
2341
+ // only an explicit success:false or ok:false is a failure, because most ops
2342
+ // return a bare value (an id, a count) and treating a missing field as
2343
+ // refusal would fail every one of them.
2344
+ const _opFailure = (v) => {
2345
+ if (!v || typeof v !== 'object') return null;
2346
+ if (v.success !== false && v.ok !== false) return null;
2347
+ return (typeof v.error === 'string' && v.error)
2348
+ || (typeof v.reason === 'string' && v.reason)
2349
+ || 'the operation reported failure without naming a reason';
2350
+ };
2207
2351
  const results = [];
2208
2352
  const itemIds = [];
2209
2353
  let success = true;
@@ -2228,10 +2372,24 @@ throw new Error('Unknown diagram mode action: ${action}');
2228
2372
  const result${index} = await (async () => {
2229
2373
  ${opCode}
2230
2374
  })();
2231
- results.push({ index: ${index}, success: true, result: result${index} });
2232
- ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
2233
- itemIds.push(result${index}.itemId);
2234
- }` : ''}
2375
+ // NOT THROWING IS NOT SUCCEEDING. An op that RETURNS { success: false }
2376
+ // — a guard that found a facade missing, a preset the engine does not
2377
+ // have, a precondition unmet — raises nothing, so this recorded it as a
2378
+ // success and the batch reported success over it. Measured on
2379
+ // applyAnimatedMask(item, 'star'): the per-op error was perfectly good
2380
+ // and the batch still said everything worked.
2381
+ const failed${index} = _opFailure(result${index});
2382
+ if (failed${index}) {
2383
+ // ATOMIC: throw WITHOUT recording, and let the catch below record it
2384
+ // once. Doing both put two entries in for one failed op.
2385
+ ${isAtomic ? `throw new Error(failed${index});` : `results.push({ index: ${index}, success: false, error: failed${index}, result: result${index} });
2386
+ success = false;`}
2387
+ } else {
2388
+ results.push({ index: ${index}, success: true, result: result${index} });
2389
+ ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
2390
+ itemIds.push(result${index}.itemId);
2391
+ }` : ''}
2392
+ }
2235
2393
  } catch (opError) {
2236
2394
  results.push({ index: ${index}, success: false, error: opError.message });
2237
2395
  ${isAtomic ? 'throw opError;' : 'success = false;'}
@@ -2243,7 +2401,14 @@ throw new Error('Unknown diagram mode action: ${action}');
2243
2401
  if (app.historyManager) app.historyManager.saveState();
2244
2402
 
2245
2403
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2246
- return { success, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
2404
+ // Name WHICH ops failed on the result itself. A caller reading a failed
2405
+ // 60-op batch should not have to scan sixty entries to learn that op 41
2406
+ // could not find a preset.
2407
+ const _failed = results.filter((r) => !r.success);
2408
+ return { success, itemIds, results, operationCount: ${operations.length},
2409
+ failedCount: _failed.length,
2410
+ ...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
2411
+ canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
2247
2412
  } catch (e) {
2248
2413
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2249
2414
  return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
@@ -2435,7 +2600,15 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
2435
2600
  const targetId = ${effItemRef};
2436
2601
  const item = app.getItemById(targetId);
2437
2602
  if (!item) throw new Error('Item not found: ' + targetId);
2438
- app.applyEffect(item, '${op.effectType || 'sparkle'}', ${effParams});
2603
+ // PROPAGATE the engine's verdict. This discarded it and returned its own
2604
+ // shape, so an effect the engine refused — an unknown preset, a facade that
2605
+ // is not loaded — was recorded as applied. The batch cannot report what the
2606
+ // op never tells it.
2607
+ const _eff = app.applyEffect(item, '${op.effectType || 'sparkle'}', ${effParams});
2608
+ if (_eff && (_eff.success === false || _eff.ok === false)) {
2609
+ return { success: false, itemId: targetId, effectType: '${op.effectType || 'sparkle'}',
2610
+ error: _eff.error || _eff.reason || 'the effect was refused without a reason' };
2611
+ }
2439
2612
  return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
2440
2613
  `;
2441
2614
  }
@@ -2490,6 +2663,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2490
2663
  return { ok: false, reason: 'app.exportEngine.readExport unavailable — update FxTool' };
2491
2664
  }
2492
2665
  return await app.exportEngine.readExport(${JSON.stringify(exportId)}, ${JSON.stringify(range)});
2666
+ })();`.trim();
2667
+ }
2668
+ /**
2669
+ * What the studio is still holding.
2670
+ *
2671
+ * `id` IS the OPFS filename and OPFS is origin-persistent, so this survives a
2672
+ * page reload: an export whose id was lost — to a dropped tool result, a
2673
+ * crashed session, a reload — is recoverable from here rather than only by
2674
+ * rendering it again. Newest first, which is the order a caller looking for
2675
+ * "the one I just made" wants.
2676
+ */
2677
+ generateListExports() {
2678
+ return `
2679
+ // What the export store is holding
2680
+ (async function() {${ENSURE_EXPORT_ENGINE}
2681
+ if (!app.exportEngine || typeof app.exportEngine.listExports !== 'function') {
2682
+ return { ok: false, reason: 'app.exportEngine.listExports unavailable — update FxTool' };
2683
+ }
2684
+ return { ok: true, exports: await app.exportEngine.listExports() };
2493
2685
  })();`.trim();
2494
2686
  }
2495
2687
  /**
@@ -2510,7 +2702,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2510
2702
  }
2511
2703
  generateAgentExport(input) {
2512
2704
  const validated = AgentExportInputSchema.parse(input);
2513
- const { platform, format, quality, framing, duration, estimateOnly } = validated;
2705
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps } = validated;
2514
2706
  const qualityLevel = quality || 'standard';
2515
2707
  const videoDuration = duration ?? 5;
2516
2708
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -2533,6 +2725,22 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2533
2725
  'print-letter': { width: 2550, height: 3300, staticFormat: 'pdf', animatedFormat: 'pdf' },
2534
2726
  };
2535
2727
  const preset = platformPresets[platform] || platformPresets['web'];
2728
+ // SCALE is the only honest size control this surface has.
2729
+ //
2730
+ // A caller who wants a smaller file reaches for a bitrate, and there is
2731
+ // nowhere to put one: VideoExporter computes its own target from
2732
+ // resolution, quality and fps via _calculateBitrate and never reads a
2733
+ // caller-supplied value — passing one would be a knob that silently does
2734
+ // nothing. Resolution is an INPUT to that calculation, so halving the
2735
+ // frame genuinely halves the encode target, and it is also what makes a
2736
+ // preview render fast. One number, both jobs, and it is real.
2737
+ //
2738
+ // Rounded to EVEN: H.264 chroma subsampling is 4:2:0, and an odd dimension
2739
+ // is rejected by the encoder rather than rounded for you.
2740
+ const even = (n) => Math.max(2, Math.round(n / 2) * 2);
2741
+ const scaled = scale !== undefined && scale !== 1
2742
+ ? { width: even(preset.width * scale), height: even(preset.height * scale) }
2743
+ : { width: preset.width, height: preset.height };
2536
2744
  // Resolve "auto" format to the platform's recommended format
2537
2745
  const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
2538
2746
  // AUDIO-ONLY EXPORT, off the visual path entirely.
@@ -2547,13 +2755,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2547
2755
  // No platform preset resolves to wav, so reaching here means it was asked
2548
2756
  // for by name. Dimensions, framing, fps and bitrate are all absent rather
2549
2757
  // than passed and ignored.
2550
- // @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio
2758
+ // @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio ensureHeavyModules
2551
2759
  if (exportFormat === 'wav') {
2552
2760
  const sr = validated.sampleRate ?? 48000;
2553
2761
  const bd = validated.bitDepth ?? 16;
2554
2762
  return `
2555
2763
  // Export: the soundtrack on its own, rendered offline
2556
- (function() {
2764
+ (async function() {${ENSURE_EXPORT_ENGINE}
2557
2765
  if (typeof app.renderSoundtrackWav !== 'function') {
2558
2766
  return { success: false, format: 'wav', error: 'app.renderSoundtrackWav unavailable — update FxTool. This build cannot export audio on its own; mp4/webm still mux the soundtrack into the video.' };
2559
2767
  }
@@ -2607,13 +2815,17 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2607
2815
  }
2608
2816
  return `
2609
2817
  // Smart export for ${platform}
2610
- (async function() {
2818
+ (async function() {${ENSURE_EXPORT_ENGINE}
2611
2819
  const platform = '${platform}';
2612
2820
  const format = '${exportFormat}';
2613
2821
  const quality = '${qualityLevel}';
2614
2822
  const framing = '${framing}';
2615
- const settings = ${JSON.stringify(qualitySettings)};
2616
- const dimensions = ${JSON.stringify({ width: preset.width, height: preset.height })};
2823
+ // An explicit fps wins over the tier's. The tiers bundle frame rate with
2824
+ // compression (draft 15 / standard 30 / high 60), so "higher quality" also
2825
+ // doubled the frame count and the render time — a surprise the schema used
2826
+ // to hide because there was no way to ask for one without the other.
2827
+ const settings = ${JSON.stringify(fps !== undefined ? { ...qualitySettings, fps } : qualitySettings)};
2828
+ const dimensions = ${JSON.stringify(scaled)};
2617
2829
 
2618
2830
  // Preflight: same resolved settings as the real export, but render nothing.
2619
2831
  // Branching HERE rather than in a separate tool is deliberate — the estimate
@@ -2679,7 +2891,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2679
2891
  try {
2680
2892
  switch (format) {
2681
2893
  case 'svg':
2682
- const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVG();
2894
+ const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();
2683
2895
  result = {
2684
2896
  success: true,
2685
2897
  platform,
@@ -2728,7 +2940,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2728
2940
  // ExportEngine._quickExportVideo overrides quality with undefined for
2729
2941
  // mp4/webm, _calculateBitrate yields NaN, and VideoEncoder.configure
2730
2942
  // rejects with 'malformed bitrate value'.
2731
- const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration} };
2943
+ // width/height ride along ONLY when a scale was asked for. The buffered
2944
+ // fallback paths otherwise export at canvas size, and quietly changing
2945
+ // that for every existing caller is not what a new optional knob does.
2946
+ const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration}${scale !== undefined && scale !== 1 ? ', width: dimensions.width, height: dimensions.height' : ''} };
2732
2947
  const blobToDataUrl = (b) => new Promise(resolve => {
2733
2948
  const r = new FileReader();
2734
2949
  r.onloadend = () => resolve(r.result);
@@ -2931,7 +3146,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2931
3146
 
2932
3147
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2933
3148
  // Check timeline for active animations
2934
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
3149
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2935
3150
  analysis.hasAnimations = true;
2936
3151
  animationSet.add('timeline');
2937
3152
  }
@@ -3077,8 +3292,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3077
3292
  return { success: false, error: 'Letter collage animation not available' };
3078
3293
  }
3079
3294
 
3080
- const result = app.animateLetterCollage(collageId, {
3081
- animationType,
3295
+ // POSITIONAL, and it was not: the engine signature is
3296
+ // animateLetterCollage(collageId, animationType, options), in PinePaper.js.
3297
+ // Passing one object put it in the animationType slot, so the type the caller
3298
+ // asked for was discarded on EVERY call and the collage animated as whatever
3299
+ // the engine falls back to. Silent: the call still returned, still reported
3300
+ // success, and the collage still moved a little, which is why it read as
3301
+ // "the flagship text effect doesn't survive export" rather than as a
3302
+ // dropped argument.
3303
+ const result = app.animateLetterCollage(collageId, animationType, {
3082
3304
  staggerDelay,
3083
3305
  animationSpeed
3084
3306
  });
@@ -3458,7 +3680,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3458
3680
  }
3459
3681
 
3460
3682
  try {
3461
- app.mapSystem.stopAnimations({
3683
+ app.mapSystem.stopRegionAnimations({
3462
3684
  regions: ${regionsStr},
3463
3685
  resetColors: ${validated.resetColors !== false}
3464
3686
  });
@@ -3502,7 +3724,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3502
3724
  }
3503
3725
 
3504
3726
  try {
3505
- const result = app.mapSystem.exportRegionCSV({
3727
+ const result = app.mapSystem.exportRegionDataCSV({
3506
3728
  includeHighlighted: ${validated.includeHighlighted !== false},
3507
3729
  includeSelected: ${validated.includeSelected !== false},
3508
3730
  includeColors: ${validated.includeColors !== false},
@@ -3531,8 +3753,9 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3531
3753
  }
3532
3754
 
3533
3755
  try {
3534
- const result = app.mapSystem.importRegionCSV({
3535
- csvText: \`${escapedCsv}\`,
3756
+ // importRegionDataCSV(csvText, options) — the TEXT is positional, and it
3757
+ // was being passed inside the options bag where nothing read it.
3758
+ const result = app.mapSystem.importRegionDataCSV(\`${escapedCsv}\`, {
3536
3759
  applyColors: ${validated.applyColors !== false},
3537
3760
  applyHighlight: ${validated.applyHighlight !== false},
3538
3761
  applySelection: ${validated.applySelection !== false}
@@ -3841,7 +4064,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3841
4064
  if (item.strokeColor) itemData.properties.strokeColor = item.strokeColor.toCSS ? item.strokeColor.toCSS() : item.strokeColor;
3842
4065
  if (item.strokeWidth) itemData.properties.strokeWidth = item.strokeWidth;
3843
4066
  if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3844
- if (item.rotation) itemData.properties.rotation = item.rotation;
4067
+ // TRUE ROTATION, not the property. create() BAKES an angle into the path
4068
+ // segments under Paper's applyMatrix default, so item.rotation reads back 0 on
4069
+ // an item that is visibly rotated. Reporting that 0 is why "I could not rotate
4070
+ // a rectangle" was filed against an engine that rotates correctly. The engine
4071
+ // records the baked offset at item.data._bakedRotation and sums the two itself
4072
+ // (PinePaper.js does exactly this); read the same sum rather than the half of
4073
+ // it that happens to be zero.
4074
+ const _rot = (item.data?._bakedRotation || 0) + (item.rotation || 0);
4075
+ if (_rot) itemData.properties.rotation = _rot;
3845
4076
  if (item.data?.content) itemData.properties.content = item.data.content;
3846
4077
  if (item.visible === false) itemData.properties.visible = false;
3847
4078
 
@@ -3895,7 +4126,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3895
4126
  }
3896
4127
 
3897
4128
  // Get background color and canvas size
3898
- const backgroundColor = app.getBackgroundColor ? app.getBackgroundColor() : null;
4129
+ const backgroundColor = app.canvasEl?.style?.backgroundColor || null;
3899
4130
  const canvasSize = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
3900
4131
 
3901
4132
  return {
@@ -3959,10 +4190,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3959
4190
  }
3960
4191
 
3961
4192
  try {
3962
- const result = app.mapSystem.exportOriginalGeoJSON({
3963
- download: ${download},
3964
- filename: ${JSON.stringify(filename)}
3965
- });
4193
+ // Two methods, not one with a flag: getOriginalGeoJSON() returns the data,
4194
+ // downloadOriginalGeoJSON(filename) hands it to the browser.
4195
+ // exportOriginalGeoJSON has never existed.
4196
+ const result = ${download}
4197
+ ? app.mapSystem.downloadOriginalGeoJSON(${JSON.stringify(filename)})
4198
+ : app.mapSystem.getOriginalGeoJSON();
4199
+ if (result === null || result === undefined) {
4200
+ return { success: false, error: 'no map is loaded, so there is no source GeoJSON to return' };
4201
+ }
3966
4202
  return result;
3967
4203
  } catch (error) {
3968
4204
  return { success: false, error: error.message };
@@ -3982,7 +4218,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3982
4218
  }
3983
4219
 
3984
4220
  try {
3985
- const result = app.mapSystem.getSourceInfo();
4221
+ const result = app.mapSystem.getMapSourceInfo();
3986
4222
  return result;
3987
4223
  } catch (error) {
3988
4224
  return { success: false, error: error.message };
@@ -4079,9 +4315,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4079
4315
  generateExecuteCustomCode(input) {
4080
4316
  const validated = ExecuteCustomCodeInputSchema.parse(input);
4081
4317
  const description = validated.description || 'Execute custom code';
4318
+ // ASYNC, because the code inside is not ours.
4319
+ //
4320
+ // This wrapped the caller's snippet in a SYNC IIFE, which does two things
4321
+ // to async work. A top-level `await` is a syntax error inside a
4322
+ // non-async function, so a snippet that awaits anything fails to parse at
4323
+ // all. And async work started without await returns a promise nobody
4324
+ // holds: the IIFE returns immediately, the tool reports success, and the
4325
+ // next call reads a canvas where the work has not happened yet.
4326
+ //
4327
+ // That is the whole async half of the engine — export, image import,
4328
+ // rigging bake, layout — unreachable from the one tool whose entire
4329
+ // purpose is reaching things the tool surface does not cover.
4330
+ //
4331
+ // An async IIFE is still a `(`-led expression statement, so the governor
4332
+ // captures its value, and executeCode's eval fallback already resolves a
4333
+ // returned promise. Both paths await it; neither needed changing.
4082
4334
  return `
4083
4335
  // ${description}
4084
- (function() {
4336
+ (async function() {
4085
4337
  try {
4086
4338
  ${validated.code}
4087
4339
  } catch (error) {
@@ -4335,7 +4587,41 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4335
4587
  return { error: 'Image tools not available. Make sure PinePaper Studio is loaded.' };
4336
4588
  }
4337
4589
  try {
4338
- const entry = await app.imageTools.uploadFromURL('${url}');
4590
+ // FETCH IT OURSELVES FIRST, to find out WHY when it fails.
4591
+ //
4592
+ // uploadFromURL sets img.src and rejects with a bare "Failed to load image
4593
+ // from URL" — an <img> onerror genuinely carries no reason, so that message
4594
+ // is the browser's limit rather than the engine being unhelpful. fetch()
4595
+ // has the reason: a status code, a CORS rejection, a refused connection, a
4596
+ // DNS failure. A caller cannot debug what the tool will not show them.
4597
+ //
4598
+ // The bytes then go in as a DATA URL, which img.src loads without touching
4599
+ // the network again — so a host that allows fetch but blocks the image
4600
+ // load (CORS on <img>, a tainted-canvas refusal) now works too.
4601
+ let src = '${url}';
4602
+ if (!src.startsWith('data:')) {
4603
+ let res;
4604
+ try {
4605
+ res = await fetch(src);
4606
+ } catch (netErr) {
4607
+ return { success: false, error: 'could not reach ' + src + ' — ' + (netErr && netErr.message ? netErr.message : 'network request failed')
4608
+ + '. This is the browser\'s network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
4609
+ }
4610
+ if (!res.ok) {
4611
+ return { success: false, error: 'the server refused ' + src + ' — HTTP ' + res.status + ' ' + (res.statusText || ''), status: res.status };
4612
+ }
4613
+ const blob = await res.blob();
4614
+ if (!blob.type.startsWith('image/')) {
4615
+ return { success: false, error: src + ' returned ' + (blob.type || 'an unknown content type') + ', not an image. Check the URL serves the file itself rather than a web page around it.', contentType: blob.type };
4616
+ }
4617
+ src = await new Promise((resolve, reject) => {
4618
+ const fr = new FileReader();
4619
+ fr.onloadend = () => resolve(fr.result);
4620
+ fr.onerror = () => reject(new Error('could not read the downloaded image'));
4621
+ fr.readAsDataURL(blob);
4622
+ });
4623
+ }
4624
+ const entry = await app.imageTools.uploadFromURL(src);
4339
4625
  const opts = ${optsLiteral};
4340
4626
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
4341
4627
  ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
@@ -5313,8 +5599,23 @@ ${resolve}
5313
5599
  (function() {
5314
5600
  const itemIds = ${JSON.stringify(input.itemIds || [])};
5315
5601
  const opts = ${JSON.stringify(opts)};
5316
- const precomp = app.createPrecomp(itemIds, opts);
5317
- return { success: true, action: 'create', precompId: precomp?.id || precomp, name: ${JSON.stringify(input.name || '')} };
5602
+
5603
+ // createPrecomp wants LIVE ITEMS. Handing it registry ids put strings into
5604
+ // Paper.js's insertChildren, which fails as "l._remove is not a function" —
5605
+ // a minified internal, from a schema that promises ids work. Resolved here,
5606
+ // and a missing one is named rather than silently dropped from the group.
5607
+ const items = [];
5608
+ const missing = [];
5609
+ for (const id of itemIds) {
5610
+ const item = app.getItemById(id);
5611
+ if (item) items.push(item); else missing.push(id);
5612
+ }
5613
+ if (missing.length) return { success: false, error: 'precomp: no such item(s): ' + missing.join(', ') };
5614
+ if (!items.length) return { success: false, error: 'precomp needs at least one item' };
5615
+
5616
+ const precomp = app.createPrecomp(items, opts);
5617
+ if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
5618
+ return { success: true, action: 'create', precompId: precomp?.id || precomp, itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
5318
5619
  })();`.trim();
5319
5620
  }
5320
5621
  case 'add':
@@ -5406,7 +5707,7 @@ ${resolve}
5406
5707
  bounds: item.bounds ? { x: item.bounds.x, y: item.bounds.y, width: item.bounds.width, height: item.bounds.height } : null,
5407
5708
  visible: item.visible,
5408
5709
  opacity: item.opacity,
5409
- rotation: item.rotation,
5710
+ rotation: (item.data?._bakedRotation || 0) + (item.rotation || 0),
5410
5711
  selected: item.selected,
5411
5712
  };
5412
5713
  })();`.trim();
@@ -5742,7 +6043,11 @@ ${resolve}
5742
6043
  // SPRITE SHEETS
5743
6044
  // ===========================================================================
5744
6045
  generateSpriteSheet(input) {
5745
- const guard = `if (!app.spriteSheetSystem) return { error: 'SpriteSheetSystem not available' };`;
6046
+ // spriteSystem is _defineLazyHeavy: undefined until ensureHeavyModules()
6047
+ // lands, so the bare guard reported "not available" on a fresh session for
6048
+ // a studio that has it. Same bug as app.exportEngine in 1.6.9.
6049
+ const guard = `if (!app.spriteSystem && typeof app.ensureHeavyModules === 'function') { try { await app.ensureHeavyModules(); } catch (_) {} }
6050
+ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
5746
6051
  switch (input.action) {
5747
6052
  case 'generate': {
5748
6053
  const opts = {};
@@ -5762,7 +6067,7 @@ ${resolve}
5762
6067
  // Generate sprite sheet from skeleton
5763
6068
  (async function() {
5764
6069
  ${guard}
5765
- const sheet = await app.spriteSheetSystem.generateSpriteSheet(${JSON.stringify(input.skeletonId || '')}, ${JSON.stringify(opts)});
6070
+ const sheet = await app.generateSpriteSheet(${JSON.stringify(input.skeletonId || '')}, ${JSON.stringify(opts)});
5766
6071
  return { success: true, action: 'generate', spriteSheetId: sheet.id, name: sheet.name, width: sheet.atlasWidth, height: sheet.atlasHeight, frameCount: sheet.frames?.size ?? sheet.frames?.length ?? 0 };
5767
6072
  })();`.trim();
5768
6073
  }
@@ -5782,7 +6087,7 @@ ${resolve}
5782
6087
  // Play sprite sheet animation
5783
6088
  (async function() {
5784
6089
  ${guard}
5785
- const player = await app.spriteSheetSystem.playSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, ${JSON.stringify(opts)});
6090
+ const player = await app.playSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, ${JSON.stringify(opts)});
5786
6091
  return { success: true, action: 'play', playerId: player.id };
5787
6092
  })();`.trim();
5788
6093
  }
@@ -5791,7 +6096,7 @@ ${resolve}
5791
6096
  // Export sprite sheet
5792
6097
  (async function() {
5793
6098
  ${guard}
5794
- const result = await app.spriteSheetSystem.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: '${input.format || 'png'}', download: true, includeMetadata: true });
6099
+ const result = await app.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: '${input.format || 'png'}', download: true, includeMetadata: true });
5795
6100
  return { success: true, action: 'export', format: '${input.format || 'png'}' };
5796
6101
  })();`.trim();
5797
6102
  default:
@@ -6149,7 +6454,8 @@ ${resolve}
6149
6454
  // Toggle rulers
6150
6455
  (function() {
6151
6456
  ${guard}
6152
- app.measurementSystem.setRulersVisible(${enabled});
6457
+ // showRulers()/hideRulers(), not a boolean setter — setRulersVisible never existed.
6458
+ app.measurementSystem[${enabled} ? 'showRulers' : 'hideRulers']();
6153
6459
  return { success: true, action: 'set_rulers', enabled: ${enabled} };
6154
6460
  })();`.trim();
6155
6461
  }
@@ -6159,7 +6465,8 @@ ${resolve}
6159
6465
  // Toggle grid
6160
6466
  (function() {
6161
6467
  ${guard}
6162
- app.measurementSystem.setGridVisible(${enabled});
6468
+ // showGrid()/hideGrid(), not a boolean setter — setGridVisible never existed.
6469
+ app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
6163
6470
  return { success: true, action: 'set_grid', enabled: ${enabled} };
6164
6471
  })();`.trim();
6165
6472
  }
@@ -6174,17 +6481,21 @@ ${resolve}
6174
6481
  const entry = app.itemRegistry.get(${itemIdStr});
6175
6482
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
6176
6483
  const b = entry.item.bounds;
6177
- return { success: true, action: 'get_dimensions', itemId: ${itemIdStr}, x: b.x, y: b.y, width: b.width, height: b.height, rotation: entry.item.rotation || 0 };
6484
+ return { success: true, action: 'get_dimensions', itemId: ${itemIdStr}, x: b.x, y: b.y, width: b.width, height: b.height, rotation: (entry.item.data?._bakedRotation || 0) + (entry.item.rotation || 0) };
6178
6485
  })();`.trim();
6179
6486
  }
6180
6487
  case 'set_snap': {
6181
- const enabled = input.enabled !== false;
6488
+ // `enabled` is deliberately unread: there is no mode to set either way.
6182
6489
  return `
6183
- // Toggle snap to grid
6490
+ // Snap to grid — refused, see below
6184
6491
  (function() {
6185
6492
  ${guard}
6186
- app.measurementSystem.setSnapToUnitEnabled(${enabled});
6187
- return { success: true, action: 'set_snap', enabled: ${enabled} };
6493
+ // NO EQUIVALENT. MeasurementSystem publishes snapCoordinate(), which snaps a
6494
+ // coordinate you hand it, and nothing that turns snapping on as a mode —
6495
+ // setSnapToUnitEnabled has never existed, so this reported success over a
6496
+ // setting that was never changed. Refused by name, with the thing that works.
6497
+ return { success: false, action: 'set_snap',
6498
+ error: 'this build has no snap-to-unit MODE to switch: the measurement system snaps a coordinate on request rather than holding a setting. Snap positions yourself before creating or moving items, or set a grid with the grid action and place on its multiples.' };
6188
6499
  })();`.trim();
6189
6500
  }
6190
6501
  default:
@@ -6696,8 +7007,19 @@ ${guard}
6696
7007
  const r = app.setFontAxes(${S(input.itemId)}, ${S(input.axes)});
6697
7008
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'axes failed' }; }
6698
7009
  // rejected is the interesting half: an axis silently ignored is the failure
6699
- // mode this surface exists to prevent.
6700
- return { success: true, action: 'set_font_axes', applied: r.applied, rejected: r.rejected || [] };
7010
+ // mode this surface exists to prevent. And NOTHING applied while something
7011
+ // was rejected is not a success — it is the call doing nothing under a
7012
+ // success, which is the same shape as an empty batch reporting ok.
7013
+ const _applied = r.applied || {};
7014
+ const _rejected = r.rejected || [];
7015
+ const _appliedCount = Array.isArray(_applied) ? _applied.length : Object.keys(_applied).length;
7016
+ if (_appliedCount === 0 && _rejected.length) {
7017
+ return { success: false, action: 'set_font_axes', applied: _applied, rejected: _rejected,
7018
+ error: 'no axis was applied. The font does not have: ' + _rejected.join(', ')
7019
+ + (r.note ? '. ' + r.note : '') };
7020
+ }
7021
+ return { success: true, action: 'set_font_axes', applied: _applied, rejected: _rejected,
7022
+ ...(r.note ? { note: r.note } : {}) };
6701
7023
  })();`.trim();
6702
7024
  }
6703
7025
  case 'list_styles': {
@@ -7540,7 +7862,15 @@ ${needWorld}
7540
7862
  if (typeof app.storyFromText !== 'function') { return { success: false, error: 'app.storyFromText unavailable — update FxTool' }; }
7541
7863
  const r = await app.storyFromText(${S(input.text)}, ${opts});
7542
7864
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the story did not assemble' }; }
7543
- return { success: true, ...r };
7865
+ // NAMED FIELDS, not a spread. assembleStory returns its parts as live Paper
7866
+ // Groups, and a Paper item's graph reaches the editor's EVENT HANDLERS.
7867
+ // Structured clone — which is what the bridge uses — refuses to carry a
7868
+ // function, so the whole result threw DataCloneError and the message did not
7869
+ // survive the wrapping: an empty error string for a story that assembled
7870
+ // fine in-page. Not a cycle; JSON.stringify succeeded on this the whole time.
7871
+ // The groups have to stay for in-page callers, so the wrapper takes what
7872
+ // travels: the spec, the ids, the count.
7873
+ return { success: true, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };
7544
7874
  })();`.trim();
7545
7875
  case 'apply_spec':
7546
7876
  return `
@@ -7964,11 +8294,23 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
7964
8294
  return { success: true, action: 'create_skeleton', skeletonId: skeletonId };`);
7965
8295
  }
7966
8296
  case 'add_bone': {
8297
+ // DEGREES IN, RADIANS OUT. The schema says degrees and every other
8298
+ // angle on this surface is degrees, but RiggingSystem stores what it is
8299
+ // given and _solveFKRecursive feeds it straight to Math.cos/Math.sin.
8300
+ // So a documented 90 was read as 90 radians and the character exploded
8301
+ // on the first pose — silently, because a wrong pose is not an error.
8302
+ // Converted here so the tool keeps the vocabulary it advertises.
8303
+ // BOTH SPELLINGS, deliberately. The engine's _angleRad prefers
8304
+ // `angleDegrees` and converts it, so the new build reads the caller's
8305
+ // intent verbatim instead of trusting arithmetic done out here. `angle`
8306
+ // carries the same value already in radians, which is what an older
8307
+ // build reads — it has no angleDegrees and would see 0. Identical
8308
+ // result on both, and the units are stated rather than implied.
7967
8309
  const config = S({
7968
8310
  ...(input.name !== undefined ? { name: input.name } : {}),
7969
8311
  ...(input.parentBoneId !== undefined ? { parentBoneId: input.parentBoneId } : {}),
7970
8312
  ...(input.length !== undefined ? { length: input.length } : {}),
7971
- ...(input.angle !== undefined ? { angle: input.angle } : {}),
8313
+ ...(input.angle !== undefined ? { angleDegrees: input.angle, angle: (input.angle * Math.PI) / 180 } : {}),
7972
8314
  ...(input.flexibility !== undefined ? { flexibility: input.flexibility } : {}),
7973
8315
  ...(input.segments !== undefined ? { segments: input.segments } : {}),
7974
8316
  });
@@ -8008,7 +8350,15 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
8008
8350
  ...(input.movingHold !== undefined ? { movingHold: input.movingHold } : {}),
8009
8351
  ...(input.holdDrift !== undefined ? { holdDrift: input.holdDrift } : {}),
8010
8352
  });
8011
- return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
8353
+ // Same unit conversion as add_bone: an inline pose is { boneId: angleDeg }
8354
+ // by the schema, and the solver reads radians. A SAVED pose is a string
8355
+ // id and is passed through untouched — those angles are already in the
8356
+ // engine's own units.
8357
+ const poseArg = (input.pose !== null && typeof input.pose === 'object')
8358
+ ? S(Object.fromEntries(Object.entries(input.pose)
8359
+ .map(([bone, deg]) => [bone, (deg * Math.PI) / 180])))
8360
+ : S(input.pose);
8361
+ return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${poseArg}, ${S(input.easing ?? 'linear')}, ${opts});
8012
8362
  if (app.historyManager) app.historyManager.saveState();
8013
8363
  if (!ok) { return { success: false, action: 'add_pose_keyframe', error: "no such skeleton, or 'pose' named a saved pose that does not exist. Pass an inline { boneId: angleDeg } map instead, or list_poses for the saved ones" }; }
8014
8364
  return { success: true, action: 'add_pose_keyframe', time: ${input.time} };`);