@pinepaper.studio/mcp-server 1.6.9 → 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.
@@ -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
  /**
@@ -1255,12 +1272,41 @@ throw new Error('Either svgString or url must be provided');
1255
1272
  if (!app.importMermaid) return { success: false, error: 'importMermaid not available — diagramSystem missing' };
1256
1273
  const mermaidText = \`${escaped}\`;
1257
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
+
1258
1304
  return {
1259
1305
  success: !!result.success,
1260
- nodeCount: result.nodes ? result.nodes.length : 0,
1261
- edgeCount: result.edges ? result.edges.length : 0,
1262
- nodes: result.nodes || [],
1263
- edges: result.edges || [],
1306
+ nodeCount: nodes.length,
1307
+ edgeCount: edges.length,
1308
+ nodes,
1309
+ edges,
1264
1310
  errors: result.errors || [],
1265
1311
  };
1266
1312
  })();
@@ -1575,8 +1621,11 @@ bg.sendToBack();
1575
1621
  if (backgroundGenerator) {
1576
1622
  codeParts.push(`
1577
1623
  // Execute background generator
1578
- if (typeof app.generators !== 'undefined' && app.generators['${backgroundGenerator}']) {
1579
- 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();
1580
1629
  }
1581
1630
  `);
1582
1631
  }
@@ -1713,8 +1762,9 @@ results;
1713
1762
  const labelStr = label ? JSON.stringify(label) : 'null';
1714
1763
  return `
1715
1764
  // Create diagram shape: ${shapeType}
1765
+ // POSITIONAL: createShape(shapeType, config). The type was being passed
1766
+ // INSIDE the config object, where nothing reads it.
1716
1767
  const shapeConfig = {
1717
- shapeType: '${shapeType}',
1718
1768
  position: { x: ${position.x}, y: ${position.y} },
1719
1769
  width: ${widthVal},
1720
1770
  height: ${heightVal},
@@ -1722,7 +1772,8 @@ const shapeConfig = {
1722
1772
  style: ${styleStr}
1723
1773
  };
1724
1774
 
1725
- 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.');
1726
1777
  const itemId = shape.data?.registryId || shape.id;
1727
1778
  app.historyManager.saveState();
1728
1779
 
@@ -1766,7 +1817,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1766
1817
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1767
1818
 
1768
1819
  const config = ${JSON.stringify(config, null, 2)};
1769
- const connector = app.diagramManager.connect(sourceItem, targetItem, config);
1820
+ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
1770
1821
  const connectorId = connector.data?.registryId || connector.id;
1771
1822
  app.historyManager.saveState();
1772
1823
 
@@ -1794,7 +1845,7 @@ if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
1794
1845
  if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
1795
1846
 
1796
1847
  const config = ${configStr};
1797
- const connector = app.diagramManager.connectPorts(
1848
+ const connector = app.diagramSystem.connectPorts(
1798
1849
  sourceItem, '${sourcePort}',
1799
1850
  targetItem, '${targetPort}',
1800
1851
  config
@@ -1811,21 +1862,31 @@ app.historyManager.saveState();
1811
1862
  generateAddPorts(input) {
1812
1863
  const validated = AddPortsInputSchema.parse(input);
1813
1864
  const { itemId, portType, ports, count } = validated;
1814
- const portsStr = ports ? JSON.stringify(ports, null, 2) : 'undefined';
1815
- 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
+ }
1816
1877
  return `
1817
1878
  // Add ports to item: ${itemId}
1818
1879
  const item = app.getItemById('${itemId}');
1819
1880
  if (!item) throw new Error('Item not found: ${itemId}');
1820
1881
 
1821
- const result = app.diagramManager.addPorts(item, {
1822
- portType: '${portType}',
1823
- ports: ${portsStr},
1824
- count: ${countVal}
1825
- });
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}');
1826
1885
  app.historyManager.saveState();
1827
1886
 
1828
- ({ 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}' });
1829
1890
  `.trim();
1830
1891
  }
1831
1892
  /**
@@ -1838,13 +1899,26 @@ app.historyManager.saveState();
1838
1899
  const optionsStr = options ? JSON.stringify(options, null, 2) : '{}';
1839
1900
  return `
1840
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() {
1841
1905
  const itemIds = ${itemIdsStr};
1842
1906
  const options = ${optionsStr};
1843
1907
 
1844
- 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);
1845
1918
  app.historyManager.saveState();
1846
1919
 
1847
- ({ layoutType: '${layoutType}', itemsAffected: result.itemsAffected || 0, success: result.success });
1920
+ return { layoutType: '${layoutType}', itemsAffected: items.length, success: true };
1921
+ })();
1848
1922
  `.trim();
1849
1923
  }
1850
1924
  /**
@@ -1857,7 +1931,10 @@ app.historyManager.saveState();
1857
1931
  return `
1858
1932
  // Get available diagram shapes
1859
1933
  const category = ${categoryFilter};
1860
- 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();
1861
1938
 
1862
1939
  ({ shapes, count: shapes.length, category: ${categoryFilter} || 'all' });
1863
1940
  `.trim();
@@ -1885,10 +1962,44 @@ const connector = app.getItemById('${connectorId}');
1885
1962
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1886
1963
 
1887
1964
  const updates = ${updatesStr};
1888
- app.diagramManager.updateConnector(connector, updates);
1889
- app.historyManager.saveState();
1890
1965
 
1891
- ({ 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 });
1892
2003
  `.trim();
1893
2004
  }
1894
2005
  /**
@@ -1902,7 +2013,7 @@ app.historyManager.saveState();
1902
2013
  const connector = app.getItemById('${connectorId}');
1903
2014
  if (!connector) throw new Error('Connector not found: ${connectorId}');
1904
2015
 
1905
- app.diagramManager.removeConnector(connector);
2016
+ app.diagramSystem.connectorManager.removeConnector(connector);
1906
2017
  app.historyManager.saveState();
1907
2018
 
1908
2019
  ({ connectorId: '${connectorId}', removed: true });
@@ -1918,19 +2029,19 @@ app.historyManager.saveState();
1918
2029
  case 'activate':
1919
2030
  return `
1920
2031
  // Activate diagram mode
1921
- app.diagramManager.activate();
2032
+ app.diagramSystem.activate();
1922
2033
  ({ action: 'activate', active: true });
1923
2034
  `.trim();
1924
2035
  case 'deactivate':
1925
2036
  return `
1926
2037
  // Deactivate diagram mode
1927
- app.diagramManager.deactivate();
2038
+ app.diagramSystem.deactivate();
1928
2039
  ({ action: 'deactivate', active: false });
1929
2040
  `.trim();
1930
2041
  case 'toggle':
1931
2042
  return `
1932
2043
  // Toggle diagram mode
1933
- const isActive = app.diagramManager.toggle();
2044
+ const isActive = app.diagramSystem.toggle();
1934
2045
  ({ action: 'toggle', active: isActive });
1935
2046
  `.trim();
1936
2047
  case 'setMode':
@@ -1938,7 +2049,7 @@ const isActive = app.diagramManager.toggle();
1938
2049
  const shapeStr = shapeType ? `, '${shapeType}'` : '';
1939
2050
  return `
1940
2051
  // Set diagram tool mode
1941
- app.diagramManager.setMode(${modeStr}${shapeStr});
2052
+ app.diagramSystem.setMode(${modeStr}${shapeStr});
1942
2053
  ({ action: 'setMode', mode: ${modeStr}${shapeType ? `, shapeType: '${shapeType}'` : ''} });
1943
2054
  `.trim();
1944
2055
  default:
@@ -2097,7 +2208,7 @@ throw new Error('Unknown diagram mode action: ${action}');
2097
2208
 
2098
2209
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2099
2210
  // Check timeline for active animations
2100
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
2211
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2101
2212
  analysis.hasAnimations = true;
2102
2213
  animationSet.add('timeline');
2103
2214
  }
@@ -2226,6 +2337,17 @@ throw new Error('Unknown diagram mode action: ${action}');
2226
2337
  let code = `
2227
2338
  // Batch execute ${operations.length} operations
2228
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
+ };
2229
2351
  const results = [];
2230
2352
  const itemIds = [];
2231
2353
  let success = true;
@@ -2250,10 +2372,24 @@ throw new Error('Unknown diagram mode action: ${action}');
2250
2372
  const result${index} = await (async () => {
2251
2373
  ${opCode}
2252
2374
  })();
2253
- results.push({ index: ${index}, success: true, result: result${index} });
2254
- ${claimsItemSlot ? `if (result${index} && result${index}.itemId) {
2255
- itemIds.push(result${index}.itemId);
2256
- }` : ''}
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
+ }
2257
2393
  } catch (opError) {
2258
2394
  results.push({ index: ${index}, success: false, error: opError.message });
2259
2395
  ${isAtomic ? 'throw opError;' : 'success = false;'}
@@ -2265,7 +2401,14 @@ throw new Error('Unknown diagram mode action: ${action}');
2265
2401
  if (app.historyManager) app.historyManager.saveState();
2266
2402
 
2267
2403
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2268
- 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 } };
2269
2412
  } catch (e) {
2270
2413
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
2271
2414
  return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
@@ -2457,7 +2600,15 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
2457
2600
  const targetId = ${effItemRef};
2458
2601
  const item = app.getItemById(targetId);
2459
2602
  if (!item) throw new Error('Item not found: ' + targetId);
2460
- 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
+ }
2461
2612
  return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
2462
2613
  `;
2463
2614
  }
@@ -2551,7 +2702,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2551
2702
  }
2552
2703
  generateAgentExport(input) {
2553
2704
  const validated = AgentExportInputSchema.parse(input);
2554
- const { platform, format, quality, framing, duration, estimateOnly, scale } = validated;
2705
+ const { platform, format, quality, framing, duration, estimateOnly, scale, fps } = validated;
2555
2706
  const qualityLevel = quality || 'standard';
2556
2707
  const videoDuration = duration ?? 5;
2557
2708
  // Quality settings (bitrate in bps for VideoEncoder)
@@ -2669,7 +2820,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2669
2820
  const format = '${exportFormat}';
2670
2821
  const quality = '${qualityLevel}';
2671
2822
  const framing = '${framing}';
2672
- const settings = ${JSON.stringify(qualitySettings)};
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)};
2673
2828
  const dimensions = ${JSON.stringify(scaled)};
2674
2829
 
2675
2830
  // Preflight: same resolved settings as the real export, but render nothing.
@@ -2736,7 +2891,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2736
2891
  try {
2737
2892
  switch (format) {
2738
2893
  case 'svg':
2739
- const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVG();
2894
+ const svgString = app.exportAnimatedSVG ? app.exportAnimatedSVG() : app.exportSVGWithCSS();
2740
2895
  result = {
2741
2896
  success: true,
2742
2897
  platform,
@@ -2991,7 +3146,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
2991
3146
 
2992
3147
  // FxTool has no animationManager — animations are detected via item.data.animationType walk above
2993
3148
  // Check timeline for active animations
2994
- if (app.timeline && (app.timeline.isPlaying || app.timeline.animations?.length > 0)) {
3149
+ if (app.timelineState?.isPlaying || app.animatedItems?.size > 0) {
2995
3150
  analysis.hasAnimations = true;
2996
3151
  animationSet.add('timeline');
2997
3152
  }
@@ -3525,7 +3680,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3525
3680
  }
3526
3681
 
3527
3682
  try {
3528
- app.mapSystem.stopAnimations({
3683
+ app.mapSystem.stopRegionAnimations({
3529
3684
  regions: ${regionsStr},
3530
3685
  resetColors: ${validated.resetColors !== false}
3531
3686
  });
@@ -3569,7 +3724,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3569
3724
  }
3570
3725
 
3571
3726
  try {
3572
- const result = app.mapSystem.exportRegionCSV({
3727
+ const result = app.mapSystem.exportRegionDataCSV({
3573
3728
  includeHighlighted: ${validated.includeHighlighted !== false},
3574
3729
  includeSelected: ${validated.includeSelected !== false},
3575
3730
  includeColors: ${validated.includeColors !== false},
@@ -3598,8 +3753,9 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3598
3753
  }
3599
3754
 
3600
3755
  try {
3601
- const result = app.mapSystem.importRegionCSV({
3602
- 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}\`, {
3603
3759
  applyColors: ${validated.applyColors !== false},
3604
3760
  applyHighlight: ${validated.applyHighlight !== false},
3605
3761
  applySelection: ${validated.applySelection !== false}
@@ -3908,7 +4064,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3908
4064
  if (item.strokeColor) itemData.properties.strokeColor = item.strokeColor.toCSS ? item.strokeColor.toCSS() : item.strokeColor;
3909
4065
  if (item.strokeWidth) itemData.properties.strokeWidth = item.strokeWidth;
3910
4066
  if (item.opacity !== undefined) itemData.properties.opacity = item.opacity;
3911
- 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;
3912
4076
  if (item.data?.content) itemData.properties.content = item.data.content;
3913
4077
  if (item.visible === false) itemData.properties.visible = false;
3914
4078
 
@@ -3962,7 +4126,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
3962
4126
  }
3963
4127
 
3964
4128
  // Get background color and canvas size
3965
- const backgroundColor = app.getBackgroundColor ? app.getBackgroundColor() : null;
4129
+ const backgroundColor = app.canvasEl?.style?.backgroundColor || null;
3966
4130
  const canvasSize = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
3967
4131
 
3968
4132
  return {
@@ -4026,10 +4190,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4026
4190
  }
4027
4191
 
4028
4192
  try {
4029
- const result = app.mapSystem.exportOriginalGeoJSON({
4030
- download: ${download},
4031
- filename: ${JSON.stringify(filename)}
4032
- });
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
+ }
4033
4202
  return result;
4034
4203
  } catch (error) {
4035
4204
  return { success: false, error: error.message };
@@ -4049,7 +4218,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4049
4218
  }
4050
4219
 
4051
4220
  try {
4052
- const result = app.mapSystem.getSourceInfo();
4221
+ const result = app.mapSystem.getMapSourceInfo();
4053
4222
  return result;
4054
4223
  } catch (error) {
4055
4224
  return { success: false, error: error.message };
@@ -4146,9 +4315,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4146
4315
  generateExecuteCustomCode(input) {
4147
4316
  const validated = ExecuteCustomCodeInputSchema.parse(input);
4148
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.
4149
4334
  return `
4150
4335
  // ${description}
4151
- (function() {
4336
+ (async function() {
4152
4337
  try {
4153
4338
  ${validated.code}
4154
4339
  } catch (error) {
@@ -4402,7 +4587,41 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
4402
4587
  return { error: 'Image tools not available. Make sure PinePaper Studio is loaded.' };
4403
4588
  }
4404
4589
  try {
4405
- 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);
4406
4625
  const opts = ${optsLiteral};
4407
4626
  const raster = await app.imageTools.placeImage(entry.id, Object.keys(opts).length > 0 ? opts : undefined);
4408
4627
  ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The REGISTRY id is \`data.id\`. \`data.itemId\` has never existed, so this
@@ -5380,8 +5599,23 @@ ${resolve}
5380
5599
  (function() {
5381
5600
  const itemIds = ${JSON.stringify(input.itemIds || [])};
5382
5601
  const opts = ${JSON.stringify(opts)};
5383
- const precomp = app.createPrecomp(itemIds, opts);
5384
- 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 || '')} };
5385
5619
  })();`.trim();
5386
5620
  }
5387
5621
  case 'add':
@@ -5473,7 +5707,7 @@ ${resolve}
5473
5707
  bounds: item.bounds ? { x: item.bounds.x, y: item.bounds.y, width: item.bounds.width, height: item.bounds.height } : null,
5474
5708
  visible: item.visible,
5475
5709
  opacity: item.opacity,
5476
- rotation: item.rotation,
5710
+ rotation: (item.data?._bakedRotation || 0) + (item.rotation || 0),
5477
5711
  selected: item.selected,
5478
5712
  };
5479
5713
  })();`.trim();
@@ -5809,7 +6043,11 @@ ${resolve}
5809
6043
  // SPRITE SHEETS
5810
6044
  // ===========================================================================
5811
6045
  generateSpriteSheet(input) {
5812
- 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' };`;
5813
6051
  switch (input.action) {
5814
6052
  case 'generate': {
5815
6053
  const opts = {};
@@ -5829,7 +6067,7 @@ ${resolve}
5829
6067
  // Generate sprite sheet from skeleton
5830
6068
  (async function() {
5831
6069
  ${guard}
5832
- 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)});
5833
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 };
5834
6072
  })();`.trim();
5835
6073
  }
@@ -5849,7 +6087,7 @@ ${resolve}
5849
6087
  // Play sprite sheet animation
5850
6088
  (async function() {
5851
6089
  ${guard}
5852
- 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)});
5853
6091
  return { success: true, action: 'play', playerId: player.id };
5854
6092
  })();`.trim();
5855
6093
  }
@@ -5858,7 +6096,7 @@ ${resolve}
5858
6096
  // Export sprite sheet
5859
6097
  (async function() {
5860
6098
  ${guard}
5861
- 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 });
5862
6100
  return { success: true, action: 'export', format: '${input.format || 'png'}' };
5863
6101
  })();`.trim();
5864
6102
  default:
@@ -6216,7 +6454,8 @@ ${resolve}
6216
6454
  // Toggle rulers
6217
6455
  (function() {
6218
6456
  ${guard}
6219
- app.measurementSystem.setRulersVisible(${enabled});
6457
+ // showRulers()/hideRulers(), not a boolean setter — setRulersVisible never existed.
6458
+ app.measurementSystem[${enabled} ? 'showRulers' : 'hideRulers']();
6220
6459
  return { success: true, action: 'set_rulers', enabled: ${enabled} };
6221
6460
  })();`.trim();
6222
6461
  }
@@ -6226,7 +6465,8 @@ ${resolve}
6226
6465
  // Toggle grid
6227
6466
  (function() {
6228
6467
  ${guard}
6229
- app.measurementSystem.setGridVisible(${enabled});
6468
+ // showGrid()/hideGrid(), not a boolean setter — setGridVisible never existed.
6469
+ app.measurementSystem[${enabled} ? 'showGrid' : 'hideGrid']();
6230
6470
  return { success: true, action: 'set_grid', enabled: ${enabled} };
6231
6471
  })();`.trim();
6232
6472
  }
@@ -6241,17 +6481,21 @@ ${resolve}
6241
6481
  const entry = app.itemRegistry.get(${itemIdStr});
6242
6482
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
6243
6483
  const b = entry.item.bounds;
6244
- 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) };
6245
6485
  })();`.trim();
6246
6486
  }
6247
6487
  case 'set_snap': {
6248
- const enabled = input.enabled !== false;
6488
+ // `enabled` is deliberately unread: there is no mode to set either way.
6249
6489
  return `
6250
- // Toggle snap to grid
6490
+ // Snap to grid — refused, see below
6251
6491
  (function() {
6252
6492
  ${guard}
6253
- app.measurementSystem.setSnapToUnitEnabled(${enabled});
6254
- 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.' };
6255
6499
  })();`.trim();
6256
6500
  }
6257
6501
  default:
@@ -6763,8 +7007,19 @@ ${guard}
6763
7007
  const r = app.setFontAxes(${S(input.itemId)}, ${S(input.axes)});
6764
7008
  if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'axes failed' }; }
6765
7009
  // rejected is the interesting half: an axis silently ignored is the failure
6766
- // mode this surface exists to prevent.
6767
- 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 } : {}) };
6768
7023
  })();`.trim();
6769
7024
  }
6770
7025
  case 'list_styles': {
@@ -7607,7 +7862,15 @@ ${needWorld}
7607
7862
  if (typeof app.storyFromText !== 'function') { return { success: false, error: 'app.storyFromText unavailable — update FxTool' }; }
7608
7863
  const r = await app.storyFromText(${S(input.text)}, ${opts});
7609
7864
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the story did not assemble' }; }
7610
- 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 };
7611
7874
  })();`.trim();
7612
7875
  case 'apply_spec':
7613
7876
  return `
@@ -8031,11 +8294,23 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
8031
8294
  return { success: true, action: 'create_skeleton', skeletonId: skeletonId };`);
8032
8295
  }
8033
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.
8034
8309
  const config = S({
8035
8310
  ...(input.name !== undefined ? { name: input.name } : {}),
8036
8311
  ...(input.parentBoneId !== undefined ? { parentBoneId: input.parentBoneId } : {}),
8037
8312
  ...(input.length !== undefined ? { length: input.length } : {}),
8038
- ...(input.angle !== undefined ? { angle: input.angle } : {}),
8313
+ ...(input.angle !== undefined ? { angleDegrees: input.angle, angle: (input.angle * Math.PI) / 180 } : {}),
8039
8314
  ...(input.flexibility !== undefined ? { flexibility: input.flexibility } : {}),
8040
8315
  ...(input.segments !== undefined ? { segments: input.segments } : {}),
8041
8316
  });
@@ -8075,7 +8350,15 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
8075
8350
  ...(input.movingHold !== undefined ? { movingHold: input.movingHold } : {}),
8076
8351
  ...(input.holdDrift !== undefined ? { holdDrift: input.holdDrift } : {}),
8077
8352
  });
8078
- 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});
8079
8362
  if (app.historyManager) app.historyManager.saveState();
8080
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" }; }
8081
8364
  return { success: true, action: 'add_pose_keyframe', time: ${input.time} };`);