@pinepaper.studio/mcp-server 1.6.11 → 1.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/dist/agent/export-manager.d.ts.map +1 -1
- package/dist/agent/export-manager.js +9 -7
- package/dist/agent/export-manager.js.map +1 -1
- package/dist/assets/repositories/iconify.d.ts +0 -5
- package/dist/assets/repositories/iconify.d.ts.map +1 -1
- package/dist/assets/repositories/iconify.js +17 -5
- package/dist/assets/repositories/iconify.js.map +1 -1
- package/dist/browser/puppeteer-controller.d.ts +16 -0
- package/dist/browser/puppeteer-controller.d.ts.map +1 -1
- package/dist/browser/puppeteer-controller.js +32 -2
- package/dist/browser/puppeteer-controller.js.map +1 -1
- package/dist/design/design-systems.d.ts +27 -2
- package/dist/design/design-systems.d.ts.map +1 -1
- package/dist/design/design-systems.js +20 -0
- package/dist/design/design-systems.js.map +1 -1
- package/dist/tools/compact-descriptions.js +1 -1
- package/dist/tools/compact-descriptions.js.map +1 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +175 -52
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/engine-methods.d.ts.map +1 -1
- package/dist/tools/engine-methods.js +8 -0
- package/dist/tools/engine-methods.js.map +1 -1
- package/dist/tools/engine-surface.d.ts +1 -1
- package/dist/tools/engine-surface.d.ts.map +1 -1
- package/dist/tools/engine-surface.js +32 -12
- package/dist/tools/engine-surface.js.map +1 -1
- package/dist/tools/handlers.d.ts +13 -2
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/tools/handlers.js +219 -13
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/minimal-descriptions.js +1 -1
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/shape-params.d.ts +36 -0
- package/dist/tools/shape-params.d.ts.map +1 -0
- package/dist/tools/shape-params.js +159 -0
- package/dist/tools/shape-params.js.map +1 -0
- package/dist/tools/stick-vocabulary.d.ts +26 -0
- package/dist/tools/stick-vocabulary.d.ts.map +1 -0
- package/dist/tools/stick-vocabulary.js +102 -0
- package/dist/tools/stick-vocabulary.js.map +1 -0
- package/dist/tools/toolkits.d.ts.map +1 -1
- package/dist/tools/toolkits.js +10 -1
- package/dist/tools/toolkits.js.map +1 -1
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +865 -122
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +191 -35
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +32 -16
- package/dist/types/schemas.js.map +1 -1
- package/dist/vendor/design/design-systems-dtcg.d.ts +9 -0
- package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -1
- package/dist/vendor/design/design-systems-dtcg.js +53 -1
- package/dist/vendor/design/design-systems-dtcg.js.map +1 -1
- package/dist/vendor/design/device-mockup-generator.d.ts +83 -0
- package/dist/vendor/design/device-mockup-generator.d.ts.map +1 -0
- package/dist/vendor/design/device-mockup-generator.js +634 -0
- package/dist/vendor/design/device-mockup-generator.js.map +1 -0
- package/dist/vendor/design/recursive-subdivision-generator.d.ts +79 -0
- package/dist/vendor/design/recursive-subdivision-generator.d.ts.map +1 -0
- package/dist/vendor/design/recursive-subdivision-generator.js +319 -0
- package/dist/vendor/design/recursive-subdivision-generator.js.map +1 -0
- package/dist/vendor/design/textile-weave-generator.d.ts +140 -0
- package/dist/vendor/design/textile-weave-generator.d.ts.map +1 -0
- package/dist/vendor/design/textile-weave-generator.js +568 -0
- package/dist/vendor/design/textile-weave-generator.js.map +1 -0
- package/manifest.json +1 -1
- package/package.json +8 -2
|
@@ -86,7 +86,7 @@ function generateCreateItemCode(itemType, position, properties) {
|
|
|
86
86
|
const params = {
|
|
87
87
|
x: position.x,
|
|
88
88
|
y: position.y,
|
|
89
|
-
...baseProperties,
|
|
89
|
+
...withRadiusAxes(baseProperties),
|
|
90
90
|
};
|
|
91
91
|
// Handle simple solid colors in params
|
|
92
92
|
if (color && !isGradient(color)) {
|
|
@@ -128,6 +128,50 @@ function generateCreateItemCode(itemType, position, properties) {
|
|
|
128
128
|
const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
|
|
129
129
|
if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
|
|
130
130
|
}
|
|
131
|
+
// RTL IS NOT SUPPORTED, AND SILENCE WAS THE WORST WAY TO SAY SO.
|
|
132
|
+
//
|
|
133
|
+
// create('text', {direction:'rtl'}) dropped the property with no error and
|
|
134
|
+
// left the base direction LTR, so an Arabic sentence's final period landed
|
|
135
|
+
// at the reader's START — visibly wrong, and attributable to nothing. The
|
|
136
|
+
// engine has no text-direction support at all: `direction` there is an arc
|
|
137
|
+
// sweep and an animation direction, and `justification` is the only
|
|
138
|
+
// text-related alignment it reads.
|
|
139
|
+
//
|
|
140
|
+
// Refused by name rather than accepted-and-dropped, and rather than adding a
|
|
141
|
+
// prop that would be dropped one layer further on. The alignment half is
|
|
142
|
+
// real and is named, because it is what a caller can actually use today.
|
|
143
|
+
const rtlAsked = params.direction ?? params.textDirection;
|
|
144
|
+
if (rtlAsked !== undefined) {
|
|
145
|
+
return `
|
|
146
|
+
// Create ${itemType} — refused, rather than silently left ltr
|
|
147
|
+
({ success: false, error: ${JSON.stringify(`direction / textDirection is not supported: this studio has no text-direction support, so ${String(rtlAsked)} would have been accepted and the text left LTR. `
|
|
148
|
+
+ 'That is visible as a right-to-left sentence whose final punctuation lands at the start. '
|
|
149
|
+
+ "You can align with justification: 'right', which is read, but the BASE direction needs engine support — "
|
|
150
|
+
+ 'pass pre-shaped text, or split the line, until it lands.')} });`.trim();
|
|
151
|
+
}
|
|
152
|
+
// AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
|
|
153
|
+
//
|
|
154
|
+
// The engine names a bad anchor through console.warn and leaves (x, y) as
|
|
155
|
+
// the centre — good behaviour in the editor, and nothing at all here, since
|
|
156
|
+
// the production build strips console.warn. A typo would therefore displace
|
|
157
|
+
// the item by half its own size, which is the exact bug `anchor` exists to
|
|
158
|
+
// remove, arriving silently. Checked before it is sent instead.
|
|
159
|
+
const ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'center', 'centre'];
|
|
160
|
+
const anchorGiven = params.anchor ?? params.origin;
|
|
161
|
+
if (anchorGiven !== undefined) {
|
|
162
|
+
const normalized = String(anchorGiven).toLowerCase().replace(/[\s_]+/g, '-');
|
|
163
|
+
if (!ANCHORS.includes(normalized)) {
|
|
164
|
+
return `
|
|
165
|
+
// Create ${itemType} — refused, rather than silently centred
|
|
166
|
+
({ success: false, error: ${JSON.stringify(`anchor ${JSON.stringify(String(anchorGiven))} is not one of ${ANCHORS.slice(0, 5).join(', ')}. `
|
|
167
|
+
+ 'The engine would leave x/y as the CENTRE and say so only in a console this tool cannot read, '
|
|
168
|
+
+ 'so the item would land half its own size away with nothing to explain it.')} });`.trim();
|
|
169
|
+
}
|
|
170
|
+
if (params.anchor !== undefined)
|
|
171
|
+
params.anchor = normalized;
|
|
172
|
+
if (params.origin !== undefined)
|
|
173
|
+
params.origin = normalized;
|
|
174
|
+
}
|
|
131
175
|
// Build the code
|
|
132
176
|
let code = `
|
|
133
177
|
// Create ${itemType} item
|
|
@@ -393,6 +437,28 @@ function normalizeKeyframePositions(keyframes) {
|
|
|
393
437
|
/**
|
|
394
438
|
* Template for keyframe animation
|
|
395
439
|
*/
|
|
440
|
+
/**
|
|
441
|
+
* AN UNKNOWN itemId IS A SILENT SUCCESS, ACROSS A WHOLE FAMILY OF TOOLS.
|
|
442
|
+
*
|
|
443
|
+
* `app.addAnimation('c0', …)` on an id that does not exist calls
|
|
444
|
+
* console.warn and returns undefined — and production strips console.warn, so
|
|
445
|
+
* over MCP nothing is said at all. The emitter then returned
|
|
446
|
+
* `{success: true}` because the call did not throw. A pilot animated a
|
|
447
|
+
* connector id that was never registered and got success for every keyframe.
|
|
448
|
+
*
|
|
449
|
+
* Emitted as a shared preamble so the check reads the same everywhere and a
|
|
450
|
+
* new tool taking an itemId can adopt it in one line. It resolves through the
|
|
451
|
+
* registry the way the engine does, and names the id it could not find —
|
|
452
|
+
* "item_7 is not on the canvas" is actionable, silence is not.
|
|
453
|
+
*/
|
|
454
|
+
function requireItem(itemId, what) {
|
|
455
|
+
return `
|
|
456
|
+
const _target = (app.getItemById && app.getItemById(${JSON.stringify(itemId)}))
|
|
457
|
+
|| (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}));
|
|
458
|
+
if (!_target) {
|
|
459
|
+
return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
|
|
460
|
+
}`.trim();
|
|
461
|
+
}
|
|
396
462
|
function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits) {
|
|
397
463
|
const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
|
|
398
464
|
const calculatedDuration = duration || Math.max(...keyframes.map(k => k.time));
|
|
@@ -407,9 +473,11 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
407
473
|
opts.timeUnits = timeUnits;
|
|
408
474
|
return `
|
|
409
475
|
// Apply keyframe animation to ${itemId}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
(
|
|
476
|
+
(function() {
|
|
477
|
+
${requireItem(itemId, 'the animation')}
|
|
478
|
+
app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
|
|
479
|
+
return { success: true, itemId: '${itemId}', duration: ${calculatedDuration}, loop: ${loop}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''} };
|
|
480
|
+
})();
|
|
413
481
|
`.trim();
|
|
414
482
|
}
|
|
415
483
|
/**
|
|
@@ -475,8 +543,62 @@ app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
|
|
|
475
543
|
/**
|
|
476
544
|
* Template for getting items
|
|
477
545
|
*/
|
|
546
|
+
/**
|
|
547
|
+
* radiusX / radiusY REACH NOTHING.
|
|
548
|
+
*
|
|
549
|
+
* The engine's create() feeds a shape's config from width, height and radius.
|
|
550
|
+
* radiusX and radiusY are not among them, so an ellipse asked for
|
|
551
|
+
* `{radiusX: 250, radiusY: 72}` was built at the DEFAULT 100x60 and reported
|
|
552
|
+
* success — measured from production as "about 90x50". This package documented
|
|
553
|
+
* that exact pair as the ellipse's properties and used it in two worked
|
|
554
|
+
* examples, so a model following the guide got the wrong size every time and
|
|
555
|
+
* nothing anywhere said why.
|
|
556
|
+
*
|
|
557
|
+
* Mapped rather than merely re-documented: the radii are the natural way to
|
|
558
|
+
* describe an ellipse, the conversion is unambiguous (a radius is half its
|
|
559
|
+
* axis), and a caller who already wrote radiusX should not have to rewrite it.
|
|
560
|
+
* An explicit width/height wins, so nothing that works today changes.
|
|
561
|
+
*
|
|
562
|
+
* A COMPATIBILITY SHIM WITH A REMOVAL CONDITION. FxTool aaaf98ae aliases the
|
|
563
|
+
* same pair in the engine's shared param normaliser, which is the better place
|
|
564
|
+
* — it fixes every door at once, not just this one. This stays until that
|
|
565
|
+
* reaches the studios this package is pointed at, which is not the same moment
|
|
566
|
+
* it reaches origin/main: mcp-server ships independently and drives whatever
|
|
567
|
+
* studio it is given. The two cannot fight, because this deletes radiusX
|
|
568
|
+
* before the call, so the engine's alias never sees it.
|
|
569
|
+
*
|
|
570
|
+
* Delete this when a studio without aaaf98ae is no longer worth supporting.
|
|
571
|
+
*/
|
|
572
|
+
function withRadiusAxes(props) {
|
|
573
|
+
const { radiusX, radiusY, ...rest } = props;
|
|
574
|
+
if (radiusX === undefined && radiusY === undefined)
|
|
575
|
+
return props;
|
|
576
|
+
const out = { ...rest };
|
|
577
|
+
if (typeof radiusX === 'number' && out.width === undefined)
|
|
578
|
+
out.width = radiusX * 2;
|
|
579
|
+
if (typeof radiusY === 'number' && out.height === undefined)
|
|
580
|
+
out.height = radiusY * 2;
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
478
583
|
function generateGetItemsCode(filter) {
|
|
479
|
-
|
|
584
|
+
// A filter OBJECT is not the same as a filter. `{}` is already handled, but
|
|
585
|
+
// so is `{ type: undefined }` and anything whose keys this function does not
|
|
586
|
+
// recognise: those have a non-zero key count and contribute no conditions,
|
|
587
|
+
// and the emitted `entries.filter(entry => )` is a SYNTAX ERROR rather than
|
|
588
|
+
// a wrong answer — the whole call dies before it runs. Decide on the
|
|
589
|
+
// conditions, not on the keys.
|
|
590
|
+
const conditions = [];
|
|
591
|
+
if (filter?.type)
|
|
592
|
+
conditions.push(`entry.type === '${filter.type}'`);
|
|
593
|
+
if (filter?.source)
|
|
594
|
+
conditions.push(`entry.source === '${filter.source}'`);
|
|
595
|
+
if (filter?.hasAnimation !== undefined) {
|
|
596
|
+
conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
|
|
597
|
+
}
|
|
598
|
+
if (filter?.hasRelation !== undefined) {
|
|
599
|
+
conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
|
|
600
|
+
}
|
|
601
|
+
if (conditions.length === 0) {
|
|
480
602
|
return `
|
|
481
603
|
// Get all items
|
|
482
604
|
const entries = app.itemRegistry.getAll();
|
|
@@ -491,19 +613,6 @@ const items = entries.map(entry => ({
|
|
|
491
613
|
({ items, count: items.length });
|
|
492
614
|
`.trim();
|
|
493
615
|
}
|
|
494
|
-
const conditions = [];
|
|
495
|
-
if (filter.type) {
|
|
496
|
-
conditions.push(`entry.type === '${filter.type}'`);
|
|
497
|
-
}
|
|
498
|
-
if (filter.source) {
|
|
499
|
-
conditions.push(`entry.source === '${filter.source}'`);
|
|
500
|
-
}
|
|
501
|
-
if (filter.hasAnimation !== undefined) {
|
|
502
|
-
conditions.push(`!!entry.item.data?.animationType === ${filter.hasAnimation}`);
|
|
503
|
-
}
|
|
504
|
-
if (filter.hasRelation !== undefined) {
|
|
505
|
-
conditions.push(`(app.getRelations(entry.id || entry.itemId).length > 0) === ${filter.hasRelation}`);
|
|
506
|
-
}
|
|
507
616
|
return `
|
|
508
617
|
// Get items with filter
|
|
509
618
|
const entries = app.itemRegistry.getAll();
|
|
@@ -801,15 +910,51 @@ const stats = app.getRelationStats();
|
|
|
801
910
|
function generateListGeneratorsCode() {
|
|
802
911
|
return `
|
|
803
912
|
// List available generators
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
913
|
+
//
|
|
914
|
+
// THREE OF SEVENTY-FOUR. getAvailableBackgroundGenerators() returned a
|
|
915
|
+
// hand-written literal of three names while the registry held 74 — nothing was
|
|
916
|
+
// broken, the other seventy-one executed fine BY NAME, they were simply
|
|
917
|
+
// invisible to anyone asking what exists. listGenerators() merges the registry
|
|
918
|
+
// and awaits it; it is newer than some studios, so the old call stays as the
|
|
919
|
+
// fallback and the result says which answer you got.
|
|
920
|
+
//
|
|
921
|
+
// Wrapped in an async IIFE deliberately: a bare top-level await parses under
|
|
922
|
+
// the governor and is a syntax error on the eval path.
|
|
923
|
+
(async function() {
|
|
924
|
+
let generators;
|
|
925
|
+
let complete = true;
|
|
926
|
+
if (typeof app.listGenerators === 'function') {
|
|
927
|
+
generators = await app.listGenerators();
|
|
928
|
+
} else {
|
|
929
|
+
generators = app.getAvailableBackgroundGenerators();
|
|
930
|
+
// The merged array carries a non-enumerable pending flag when the registry
|
|
931
|
+
// chunk has not landed, so a cold page honestly answers three.
|
|
932
|
+
complete = !generators || generators.pending !== true;
|
|
933
|
+
}
|
|
934
|
+
// PARAMS ARE THE WHOLE POINT OF ASKING.
|
|
935
|
+
//
|
|
936
|
+
// The tool says it lists generators "with their parameters" and this mapper
|
|
937
|
+
// dropped the params key, so every answer was a name and a sentence. A
|
|
938
|
+
// caller who wanted to know what drawGradientMesh accepts had to guess, and
|
|
939
|
+
// an unknown generatorParams key is accepted in silence — so a guess that
|
|
940
|
+
// was wrong looked exactly like one that was right.
|
|
941
|
+
const formatted = (generators || []).map(g => ({
|
|
942
|
+
name: g.name,
|
|
943
|
+
displayName: g.displayName || g.name,
|
|
944
|
+
category: g.category || 'background',
|
|
945
|
+
description: g.description || '',
|
|
946
|
+
...(g.params ? { params: g.params } : {})
|
|
947
|
+
}));
|
|
811
948
|
|
|
812
|
-
|
|
949
|
+
return {
|
|
950
|
+
generators: formatted,
|
|
951
|
+
count: formatted.length,
|
|
952
|
+
...(complete ? {} : {
|
|
953
|
+
partial: true,
|
|
954
|
+
note: 'the generator registry had not finished loading, so this is a partial list — the built-ins only. Retry for the full set.',
|
|
955
|
+
}),
|
|
956
|
+
};
|
|
957
|
+
})();
|
|
813
958
|
`.trim();
|
|
814
959
|
}
|
|
815
960
|
/**
|
|
@@ -1041,6 +1186,107 @@ const ENSURE_EXPORT_ENGINE = `
|
|
|
1041
1186
|
if (!app.exportEngine && typeof app.ensureHeavyModules === 'function') {
|
|
1042
1187
|
try { await app.ensureHeavyModules(); } catch (_) { /* the guards below still speak */ }
|
|
1043
1188
|
}`;
|
|
1189
|
+
/**
|
|
1190
|
+
* A REMOTE <image> INSIDE AN SVG TAINTS THE CANVAS, PERMANENTLY.
|
|
1191
|
+
*
|
|
1192
|
+
* paper.importSVG draws a cross-origin raster straight onto the canvas. The
|
|
1193
|
+
* canvas then has an origin-dirty flag, and every export afterwards throws
|
|
1194
|
+
* "Tainted canvases may not be exported" — not just the one containing the
|
|
1195
|
+
* image. Nothing recovers it but a page reload, so ONE import poisons the
|
|
1196
|
+
* whole session, long after the call that did it returned success.
|
|
1197
|
+
*
|
|
1198
|
+
* Reported from production: import_svg with a picsum href answered
|
|
1199
|
+
* success/itemsCreated:0 and then broke every later export.
|
|
1200
|
+
*
|
|
1201
|
+
* So each remote href is fetched and inlined as a data: URL before the SVG
|
|
1202
|
+
* goes anywhere near the importer — the same thing import_image does, for the
|
|
1203
|
+
* same reason. An href that CANNOT be inlined has its element removed: a
|
|
1204
|
+
* missing picture is a visible, local, recoverable problem, and leaving it in
|
|
1205
|
+
* is an unrecoverable one that surfaces somewhere else entirely.
|
|
1206
|
+
*/
|
|
1207
|
+
const INLINE_REMOTE_IMAGES = `
|
|
1208
|
+
async function inlineRemoteImages(svgText) {
|
|
1209
|
+
const notes = [];
|
|
1210
|
+
const hrefs = [];
|
|
1211
|
+
const re = /(?:xlink:)?href\\s*=\\s*("|')(https?:\\/\\/[^"']+)\\1/gi;
|
|
1212
|
+
let m;
|
|
1213
|
+
while ((m = re.exec(svgText)) !== null) if (hrefs.indexOf(m[2]) === -1) hrefs.push(m[2]);
|
|
1214
|
+
if (hrefs.length === 0) return { svg: svgText, notes: notes };
|
|
1215
|
+
for (const href of hrefs) {
|
|
1216
|
+
let dataUrl = null;
|
|
1217
|
+
try {
|
|
1218
|
+
const r = await fetch(href, { mode: 'cors' });
|
|
1219
|
+
if (r.ok) {
|
|
1220
|
+
const blob = await r.blob();
|
|
1221
|
+
dataUrl = await new Promise(function(res, rej) {
|
|
1222
|
+
const fr = new FileReader();
|
|
1223
|
+
fr.onload = function() { res(fr.result); };
|
|
1224
|
+
fr.onerror = function() { rej(new Error('could not read ' + href)); };
|
|
1225
|
+
fr.readAsDataURL(blob);
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
} catch (e) { /* fall through to removal */ }
|
|
1229
|
+
if (dataUrl) {
|
|
1230
|
+
svgText = svgText.split(href).join(dataUrl);
|
|
1231
|
+
} else {
|
|
1232
|
+
// Drop the element rather than let it taint the canvas.
|
|
1233
|
+
const esc = href.replace(/[.*+?^\\\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
1234
|
+
svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[^>]*\\\\/?>', 'gi'), '');
|
|
1235
|
+
svgText = svgText.replace(new RegExp('<image\\\\b[^>]*' + esc + '[\\\\s\\\\S]*?<\\\\/image>', 'gi'), '');
|
|
1236
|
+
notes.push({
|
|
1237
|
+
url: href,
|
|
1238
|
+
action: 'removed',
|
|
1239
|
+
reason: 'unfetchable',
|
|
1240
|
+
message: 'removed an embedded image this page could not fetch (' + href + '). It was dropped rather than imported, because a cross-origin raster taints the canvas and every later export would have failed with "Tainted canvases may not be exported" until a reload.',
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return { svg: svgText, notes: notes };
|
|
1245
|
+
}
|
|
1246
|
+
`;
|
|
1247
|
+
/**
|
|
1248
|
+
* A HEX COLOUR IN THE 3D WORLD POISONS EVERY LATER EXPORT.
|
|
1249
|
+
*
|
|
1250
|
+
* addWorldObject hands `color` to a WebGL uniform — its own example is
|
|
1251
|
+
* `color: [0.9, 0.3, 0.2]`, three floats in 0..1. Give it '#ef4444' and
|
|
1252
|
+
* uniform3fv throws "cannot be converted to a sequence"; the object is
|
|
1253
|
+
* reported as added, and then EVERY subsequent MP4 export fails, because the
|
|
1254
|
+
* broken uniform is in the scene from then on. Same shape as the tainted
|
|
1255
|
+
* canvas: one bad value, success reported, and the damage surfaces somewhere
|
|
1256
|
+
* else entirely.
|
|
1257
|
+
*
|
|
1258
|
+
* Converted here. A caller writing '#ef4444' is not wrong — it is the colour
|
|
1259
|
+
* spelling every other tool in this package takes — so the emitter absorbs the
|
|
1260
|
+
* difference rather than the caller learning a second convention.
|
|
1261
|
+
*/
|
|
1262
|
+
function world3dColor(value) {
|
|
1263
|
+
if (Array.isArray(value)) {
|
|
1264
|
+
// Already a triple. Accept 0-255 too, since that is the other common form.
|
|
1265
|
+
const nums = value.filter((v) => typeof v === 'number');
|
|
1266
|
+
if (nums.length < 3)
|
|
1267
|
+
return value;
|
|
1268
|
+
return nums.slice(0, 3).map((n) => (n > 1 ? n / 255 : n));
|
|
1269
|
+
}
|
|
1270
|
+
if (typeof value !== 'string')
|
|
1271
|
+
return value;
|
|
1272
|
+
const hex = value.trim().replace(/^#/, '');
|
|
1273
|
+
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
|
1274
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
1275
|
+
return value;
|
|
1276
|
+
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
|
|
1277
|
+
}
|
|
1278
|
+
/** Recursively convert every `color` field in a world3d payload. */
|
|
1279
|
+
function world3dColors(value) {
|
|
1280
|
+
if (Array.isArray(value))
|
|
1281
|
+
return value.map((v) => world3dColors(v));
|
|
1282
|
+
if (!value || typeof value !== 'object')
|
|
1283
|
+
return value;
|
|
1284
|
+
const out = {};
|
|
1285
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1286
|
+
out[k] = k === 'color' ? world3dColor(v) : world3dColors(v);
|
|
1287
|
+
}
|
|
1288
|
+
return out;
|
|
1289
|
+
}
|
|
1044
1290
|
export class PinePaperCodeGenerator {
|
|
1045
1291
|
/**
|
|
1046
1292
|
* Generate code for creating an item
|
|
@@ -1265,7 +1511,10 @@ export class PinePaperCodeGenerator {
|
|
|
1265
1511
|
if (!response.ok) {
|
|
1266
1512
|
return { success: false, error: 'the server refused ${url} — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
|
|
1267
1513
|
}
|
|
1268
|
-
|
|
1514
|
+
let svgText = await response.text();
|
|
1515
|
+
${INLINE_REMOTE_IMAGES}
|
|
1516
|
+
const _inl = await inlineRemoteImages(svgText);
|
|
1517
|
+
svgText = _inl.svg;
|
|
1269
1518
|
const _r = ${importExpr('svgText')};
|
|
1270
1519
|
const imported = _r.item;
|
|
1271
1520
|
if (!imported) {
|
|
@@ -1275,7 +1524,8 @@ export class PinePaperCodeGenerator {
|
|
|
1275
1524
|
imported.scale(${scale});
|
|
1276
1525
|
const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
|
|
1277
1526
|
app.historyManager.saveState();
|
|
1278
|
-
return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} }
|
|
1527
|
+
return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
|
|
1528
|
+
...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
|
|
1279
1529
|
})();
|
|
1280
1530
|
`.trim();
|
|
1281
1531
|
}
|
|
@@ -1284,18 +1534,23 @@ export class PinePaperCodeGenerator {
|
|
|
1284
1534
|
const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
1285
1535
|
return `
|
|
1286
1536
|
// Import SVG string${source === 'figma' ? ' (Figma-normalised)' : ''}
|
|
1287
|
-
|
|
1537
|
+
(async function() {
|
|
1538
|
+
let svgString = \`${escapedSvg}\`;
|
|
1539
|
+
${INLINE_REMOTE_IMAGES}
|
|
1540
|
+
const _inl = await inlineRemoteImages(svgString);
|
|
1541
|
+
svgString = _inl.svg;
|
|
1288
1542
|
const _r = ${importExpr('svgString')};
|
|
1289
1543
|
const imported = _r.item;
|
|
1290
|
-
if (imported) {
|
|
1291
|
-
imported.position = new paper.Point(${position.x}, ${position.y});
|
|
1292
|
-
imported.scale(${scale});
|
|
1293
|
-
const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
|
|
1294
|
-
app.historyManager.saveState();
|
|
1295
|
-
({ success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} } });
|
|
1296
|
-
} else {
|
|
1544
|
+
if (!imported) {
|
|
1297
1545
|
throw new Error('Failed to import SVG');
|
|
1298
1546
|
}
|
|
1547
|
+
imported.position = new paper.Point(${position.x}, ${position.y});
|
|
1548
|
+
imported.scale(${scale});
|
|
1549
|
+
const itemId = app.registerItem(imported, 'svg-import', { source: 'mcp' });
|
|
1550
|
+
app.historyManager.saveState();
|
|
1551
|
+
return { success: true, itemId, changes: _r.changes, position: { x: ${position.x}, y: ${position.y} },
|
|
1552
|
+
...(_inl.notes.length ? { imageWarnings: _inl.notes } : {}) };
|
|
1553
|
+
})();
|
|
1299
1554
|
`.trim();
|
|
1300
1555
|
}
|
|
1301
1556
|
return `
|
|
@@ -1865,7 +2120,25 @@ const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
|
|
|
1865
2120
|
const connectorId = connector.data?.registryId || connector.id;
|
|
1866
2121
|
app.historyManager.saveState();
|
|
1867
2122
|
|
|
1868
|
-
|
|
2123
|
+
// A REQUESTED id IS NOT NECESSARILY THE id YOU GOT.
|
|
2124
|
+
//
|
|
2125
|
+
// Connector itself honours 'config.id || <generated>', but DiagramSystem.connect
|
|
2126
|
+
// rebuilds the config from an allowlist — routing, lineColor, lineWidth,
|
|
2127
|
+
// headStyle, headSize, boltEnabled — and 'id' is not on it. So a caller's id is
|
|
2128
|
+
// dropped before the Connector sees it and a timestamp id is minted instead,
|
|
2129
|
+
// which then makes update_connector / remove_connector say "not found" for the
|
|
2130
|
+
// id the caller chose. Engine-side, raised with fxtool; until it lands, the
|
|
2131
|
+
// mismatch is REPORTED rather than left for a later call to trip over.
|
|
2132
|
+
({
|
|
2133
|
+
connectorId,
|
|
2134
|
+
sourceItemId: '${sourceItemId}',
|
|
2135
|
+
targetItemId: '${targetItemId}',
|
|
2136
|
+
...(${JSON.stringify(id ?? null)} && connectorId !== ${JSON.stringify(id ?? null)}
|
|
2137
|
+
? { requestedId: ${JSON.stringify(id ?? null)},
|
|
2138
|
+
idHonoured: false,
|
|
2139
|
+
note: 'this studio dropped the id you asked for and minted its own — use connectorId above for update_connector and remove_connector, not the one you passed.' }
|
|
2140
|
+
: {}),
|
|
2141
|
+
});
|
|
1869
2142
|
`.trim();
|
|
1870
2143
|
}
|
|
1871
2144
|
/**
|
|
@@ -2217,11 +2490,28 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2217
2490
|
hasImages: false,
|
|
2218
2491
|
};
|
|
2219
2492
|
|
|
2220
|
-
//
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2493
|
+
// THE ARTBOARD, NOT THE BACKING STORE.
|
|
2494
|
+
//
|
|
2495
|
+
// canvasEl.width and paper.view.size are both in device pixels, so a
|
|
2496
|
+
// 1920x1080 artboard was reported as 2233x1472 — measured from production.
|
|
2497
|
+
// That is not even the device pixel ratio (a 2233px store in a 924px box is
|
|
2498
|
+
// 2.4166), so a caller cannot divide it back out. Anything derived from it
|
|
2499
|
+
// is wrong by an unknown factor, and synthetic clicks computed from it miss
|
|
2500
|
+
// in silence. getCanvasSize() is the artboard the caller set.
|
|
2501
|
+
if (typeof app.getCanvasSize === 'function') {
|
|
2502
|
+
const cs = app.getCanvasSize();
|
|
2503
|
+
if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
|
|
2504
|
+
}
|
|
2505
|
+
if (!analysis.canvasSize.width) {
|
|
2506
|
+
if (app.canvasSize && app.canvasSize.width) {
|
|
2507
|
+
analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
|
|
2508
|
+
} else if (app.canvasEl) {
|
|
2509
|
+
// Last resort on a studio with neither: a backing-store size is better
|
|
2510
|
+
// than zero, and it is flagged so nobody treats it as the artboard.
|
|
2511
|
+
analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
|
|
2512
|
+
} else if (paper.view) {
|
|
2513
|
+
analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
|
|
2514
|
+
}
|
|
2225
2515
|
}
|
|
2226
2516
|
|
|
2227
2517
|
// Count items and analyze
|
|
@@ -2261,12 +2551,16 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2261
2551
|
|
|
2262
2552
|
// Check relations
|
|
2263
2553
|
if (app.relationRegistry) {
|
|
2264
|
-
|
|
2265
|
-
analysis
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2554
|
+
// getAll() has never existed on the registry, so this branch always took
|
|
2555
|
+
// the empty fallback and every analysis reported NO relations. getStats()
|
|
2556
|
+
// is the real accessor and answers both questions directly:
|
|
2557
|
+
// associationsByType is keyed by relation type.
|
|
2558
|
+
const relStats = typeof app.relationRegistry.getStats === 'function'
|
|
2559
|
+
? app.relationRegistry.getStats()
|
|
2560
|
+
: null;
|
|
2561
|
+
(Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
|
|
2269
2562
|
analysis.relationTypes = Array.from(relationSet);
|
|
2563
|
+
analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
|
|
2270
2564
|
if (analysis.hasRelations) analysis.hasAnimations = true;
|
|
2271
2565
|
}
|
|
2272
2566
|
|
|
@@ -2468,7 +2762,7 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
2468
2762
|
switch (op.type) {
|
|
2469
2763
|
case 'create': {
|
|
2470
2764
|
const pos = op.position || { x: 400, y: 300 };
|
|
2471
|
-
const createProps = (op.properties || {});
|
|
2765
|
+
const createProps = withRadiusAxes((op.properties || {}));
|
|
2472
2766
|
const props = JSON.stringify(createProps);
|
|
2473
2767
|
let createCode = `
|
|
2474
2768
|
const item = app.create('${op.itemType}', { position: { x: ${pos.x}, y: ${pos.y} }, ...${props} });
|
|
@@ -2796,6 +3090,19 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2796
3090
|
const scaled = scale !== undefined && scale !== 1
|
|
2797
3091
|
? { width: even(preset.width * scale), height: even(preset.height * scale) }
|
|
2798
3092
|
: { width: preset.width, height: preset.height };
|
|
3093
|
+
// 'auto' MEANS THE CANVAS, NOT A PRESET THAT HAPPENS TO BE FIRST.
|
|
3094
|
+
//
|
|
3095
|
+
// `platform` defaults to 'auto', 'auto' is not a key in platformPresets,
|
|
3096
|
+
// and the lookup falls through to `web` — so an export that named no
|
|
3097
|
+
// platform silently rendered 800x600. A beta probe measured exactly that:
|
|
3098
|
+
// three exports of a 960x540 canvas all came back 800x600, reported as a
|
|
3099
|
+
// dimension mismatch because from outside it is one. Nothing said the
|
|
3100
|
+
// canvas had been replaced by a preset.
|
|
3101
|
+
//
|
|
3102
|
+
// Resolved in the generated code rather than here, because the canvas size
|
|
3103
|
+
// is only known in the page. The preset stays the fallback for a studio
|
|
3104
|
+
// too old to report one, and `scale` still applies either way.
|
|
3105
|
+
const usesCanvasSize = !platformPresets[platform];
|
|
2799
3106
|
// Resolve "auto" format to the platform's recommended format
|
|
2800
3107
|
const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
|
|
2801
3108
|
// AUDIO-ONLY EXPORT, off the visual path entirely.
|
|
@@ -2880,7 +3187,35 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2880
3187
|
// doubled the frame count and the render time — a surprise the schema used
|
|
2881
3188
|
// to hide because there was no way to ask for one without the other.
|
|
2882
3189
|
const settings = ${JSON.stringify(fps !== undefined ? { ...qualitySettings, fps } : qualitySettings)};
|
|
2883
|
-
|
|
3190
|
+
${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as a fallback for a
|
|
3191
|
+
// studio too old to report one. Named platforms emit a plain object literal
|
|
3192
|
+
// just below, so what they render stays readable in the code itself.
|
|
3193
|
+
const __presetDims = ${JSON.stringify(scaled)};
|
|
3194
|
+
const __canvasDims = app.canvasSize || (app.view && app.view.viewSize) || null;
|
|
3195
|
+
const __even = (n) => Math.max(2, Math.round((n * ${scale !== undefined ? scale : 1}) / 2) * 2);
|
|
3196
|
+
const dimensions = (__canvasDims && __canvasDims.width && __canvasDims.height)
|
|
3197
|
+
? { width: __even(__canvasDims.width), height: __even(__canvasDims.height) }
|
|
3198
|
+
: __presetDims;` : ` const dimensions = ${JSON.stringify(scaled)};`}
|
|
3199
|
+
|
|
3200
|
+
// WHAT THIS SCENE LOSES TO THIS FORMAT, asked at the moment you would care.
|
|
3201
|
+
//
|
|
3202
|
+
// exportFidelity walks the actual scene: a PNG of an animated scene is one
|
|
3203
|
+
// frame, a GIF with a soundtrack drops it, a wav of a silent scene is
|
|
3204
|
+
// silence. The engine derived it so that 117 tool descriptions would not
|
|
3205
|
+
// have to answer "does this survive export?" by hand and drift.
|
|
3206
|
+
//
|
|
3207
|
+
// Reported ONLY when there is something to report. An empty list would read
|
|
3208
|
+
// as "this format is lossless", and it does not mean that — it means this
|
|
3209
|
+
// scene has nothing THIS format will lose. Omitting it entirely is the one
|
|
3210
|
+
// shape that cannot be misread, and it keeps a clean export quiet.
|
|
3211
|
+
function fidelity(fmt) {
|
|
3212
|
+
try {
|
|
3213
|
+
if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') return {};
|
|
3214
|
+
const r = app.exportEngine.exportFidelity(fmt);
|
|
3215
|
+
if (!r || !r.warnings || r.warnings.length === 0) return {};
|
|
3216
|
+
return { fidelity: { warnings: r.warnings, checked: r.checked } };
|
|
3217
|
+
} catch (e) { return {}; }
|
|
3218
|
+
}
|
|
2884
3219
|
|
|
2885
3220
|
// Preflight: same resolved settings as the real export, but render nothing.
|
|
2886
3221
|
// Branching HERE rather than in a separate tool is deliberate — the estimate
|
|
@@ -2906,6 +3241,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2906
3241
|
fps: settings.fps,
|
|
2907
3242
|
dimensions: dimensions.width + 'x' + dimensions.height,
|
|
2908
3243
|
estimate: est,
|
|
3244
|
+
...fidelity(format),
|
|
2909
3245
|
};
|
|
2910
3246
|
}
|
|
2911
3247
|
|
|
@@ -2932,13 +3268,38 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2932
3268
|
if (!params || (params.mode || 'keyframes') !== 'keyframes' || !params.keyframes || !params.keyframes.length) {
|
|
2933
3269
|
return { success: false, platform, format, framing, error: 'framing: "camera" requires a keyframe-mode camera walkthrough with at least one keyframe.' };
|
|
2934
3270
|
}
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3271
|
+
// MATCH THE ENGINE'S FRAME EXACTLY, AND DO NOT SECOND-GUESS THE ZOOM.
|
|
3272
|
+
//
|
|
3273
|
+
// This divided canvasEl.width — device pixels — so a camera export came
|
|
3274
|
+
// out 2234x1472 whatever the board was. That is fixed by reading the
|
|
3275
|
+
// artboard, and the chain below is deliberately the same one the engine's
|
|
3276
|
+
// own _exportFrameSize() uses: getCanvasSize(), then app.canvasSize, then
|
|
3277
|
+
// a known frame. Two places computing one number is how they drift.
|
|
3278
|
+
//
|
|
3279
|
+
// The zoom divide is gone too, and that is the part worth explaining.
|
|
3280
|
+
// VideoExporter._frameCropRect returns the WHOLE canvas element whenever a
|
|
3281
|
+
// camera animation exists — the camera owns the view, so it captures
|
|
3282
|
+
// everything the camera frames. Dividing by the first keyframe's zoom
|
|
3283
|
+
// therefore never reframed anything; it only shrank the output, so a
|
|
3284
|
+
// zoom-2 export produced a half-size video of the same content. Asking for
|
|
3285
|
+
// the artboard's own size is both simpler and right.
|
|
3286
|
+
//
|
|
3287
|
+
// NOTE: the engine currently scales that whole-canvas crop into the output
|
|
3288
|
+
// frame without preserving aspect, so the picture is squashed by the ratio
|
|
3289
|
+
// between the workspace canvas and the board (measured: 1.17 landscape,
|
|
3290
|
+
// 0.37 portrait). That is VideoExporter._frameCropRect, not this — raised
|
|
3291
|
+
// with fxtool-f2. Matching frames here means that when it is fixed, these
|
|
3292
|
+
// two cannot disagree about the aspect on top of it.
|
|
3293
|
+
let camBase = null;
|
|
3294
|
+
if (typeof app.getCanvasSize === 'function') {
|
|
3295
|
+
const cs = app.getCanvasSize();
|
|
3296
|
+
if (cs && cs.width > 0) camBase = { width: cs.width, height: cs.height };
|
|
3297
|
+
}
|
|
3298
|
+
if (!camBase && app.canvasSize && app.canvasSize.width > 0) {
|
|
3299
|
+
camBase = { width: app.canvasSize.width, height: app.canvasSize.height };
|
|
3300
|
+
}
|
|
3301
|
+
if (!camBase) camBase = { width: dimensions.width, height: dimensions.height };
|
|
3302
|
+
cameraDims = { width: camBase.width, height: camBase.height };
|
|
2942
3303
|
}
|
|
2943
3304
|
|
|
2944
3305
|
let result = { success: false, platform, format, quality, framing };
|
|
@@ -2960,14 +3321,35 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2960
3321
|
|
|
2961
3322
|
case 'png':
|
|
2962
3323
|
if (app.exportEngine && app.exportEngine.exportPNG) {
|
|
2963
|
-
|
|
3324
|
+
// PNG IGNORED THE PLATFORM ENTIRELY.
|
|
3325
|
+
//
|
|
3326
|
+
// exportPNG takes dpi or scale and no dimensions, so passing only
|
|
3327
|
+
// dpi scaled the CANVAS by dpi/96 and the preset was never applied:
|
|
3328
|
+
// platform 'youtube-thumbnail' (1280x720) and platform 'auto' on a
|
|
3329
|
+
// 1920x1080 board both returned 3000x1688, which is 1920 x 150/96.
|
|
3330
|
+
// Third member of the same family as the camera dims and analyze —
|
|
3331
|
+
// a pixel count that came from the rendering scale rather than from
|
|
3332
|
+
// what the caller asked for.
|
|
3333
|
+
//
|
|
3334
|
+
// dimensions is already resolved: the preset for a named platform,
|
|
3335
|
+
// the board itself for auto. Turning it into a scale is the only
|
|
3336
|
+
// lever exportPNG offers, so that is what it gets.
|
|
3337
|
+
const pngBoard = (typeof app.getCanvasSize === 'function' && app.getCanvasSize())
|
|
3338
|
+
|| app.canvasSize
|
|
3339
|
+
|| { width: dimensions.width, height: dimensions.height };
|
|
3340
|
+
const pngScale = pngBoard.width > 0 ? dimensions.width / pngBoard.width : 1;
|
|
3341
|
+
const png = await app.exportEngine.exportPNG({ scale: pngScale });
|
|
3342
|
+
const dataUrl = (png && png.dataUrl) ? png.dataUrl : png;
|
|
2964
3343
|
result = {
|
|
2965
3344
|
success: true,
|
|
2966
3345
|
platform,
|
|
2967
3346
|
format: 'png',
|
|
2968
3347
|
data: dataUrl,
|
|
2969
3348
|
mimeType: 'image/png',
|
|
2970
|
-
size: Math.round(dataUrl.length * 0.75)
|
|
3349
|
+
size: Math.round(String(dataUrl).length * 0.75),
|
|
3350
|
+
dimensions: (png && png.width)
|
|
3351
|
+
? { width: png.width, height: png.height }
|
|
3352
|
+
: { width: dimensions.width, height: dimensions.height },
|
|
2971
3353
|
};
|
|
2972
3354
|
} else {
|
|
2973
3355
|
const canvas = document.querySelector('canvas');
|
|
@@ -3135,6 +3517,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3135
3517
|
result = { success: false, error: e.message, platform, format };
|
|
3136
3518
|
}
|
|
3137
3519
|
|
|
3520
|
+
// One site, after every branch has set its result: a successful export says
|
|
3521
|
+
// what this scene lost to this format, rather than the caller finding out by
|
|
3522
|
+
// opening the file. Only when there is something to say — see fidelity().
|
|
3523
|
+
if (result && result.success) Object.assign(result, fidelity(format));
|
|
3524
|
+
|
|
3138
3525
|
return result;
|
|
3139
3526
|
})();
|
|
3140
3527
|
`.trim();
|
|
@@ -3161,11 +3548,28 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3161
3548
|
hasImages: false,
|
|
3162
3549
|
};
|
|
3163
3550
|
|
|
3164
|
-
//
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3551
|
+
// THE ARTBOARD, NOT THE BACKING STORE.
|
|
3552
|
+
//
|
|
3553
|
+
// canvasEl.width and paper.view.size are both in device pixels, so a
|
|
3554
|
+
// 1920x1080 artboard was reported as 2233x1472 — measured from production.
|
|
3555
|
+
// That is not even the device pixel ratio (a 2233px store in a 924px box is
|
|
3556
|
+
// 2.4166), so a caller cannot divide it back out. Anything derived from it
|
|
3557
|
+
// is wrong by an unknown factor, and synthetic clicks computed from it miss
|
|
3558
|
+
// in silence. getCanvasSize() is the artboard the caller set.
|
|
3559
|
+
if (typeof app.getCanvasSize === 'function') {
|
|
3560
|
+
const cs = app.getCanvasSize();
|
|
3561
|
+
if (cs && cs.width) analysis.canvasSize = { width: cs.width, height: cs.height };
|
|
3562
|
+
}
|
|
3563
|
+
if (!analysis.canvasSize.width) {
|
|
3564
|
+
if (app.canvasSize && app.canvasSize.width) {
|
|
3565
|
+
analysis.canvasSize = { width: app.canvasSize.width, height: app.canvasSize.height };
|
|
3566
|
+
} else if (app.canvasEl) {
|
|
3567
|
+
// Last resort on a studio with neither: a backing-store size is better
|
|
3568
|
+
// than zero, and it is flagged so nobody treats it as the artboard.
|
|
3569
|
+
analysis.canvasSize = { width: app.canvasEl.width, height: app.canvasEl.height, backingStore: true };
|
|
3570
|
+
} else if (paper.view) {
|
|
3571
|
+
analysis.canvasSize = { width: paper.view.size.width, height: paper.view.size.height, backingStore: true };
|
|
3572
|
+
}
|
|
3169
3573
|
}
|
|
3170
3574
|
|
|
3171
3575
|
// Count items and analyze types
|
|
@@ -3212,12 +3616,16 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3212
3616
|
|
|
3213
3617
|
// Check relations
|
|
3214
3618
|
if (app.relationRegistry) {
|
|
3215
|
-
|
|
3216
|
-
analysis
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3619
|
+
// getAll() has never existed on the registry, so this branch always took
|
|
3620
|
+
// the empty fallback and every analysis reported NO relations. getStats()
|
|
3621
|
+
// is the real accessor and answers both questions directly:
|
|
3622
|
+
// associationsByType is keyed by relation type.
|
|
3623
|
+
const relStats = typeof app.relationRegistry.getStats === 'function'
|
|
3624
|
+
? app.relationRegistry.getStats()
|
|
3625
|
+
: null;
|
|
3626
|
+
(Object.keys((relStats && relStats.associationsByType) || {})).forEach(t => relationSet.add(t));
|
|
3220
3627
|
analysis.relationTypes = Array.from(relationSet);
|
|
3628
|
+
analysis.hasRelations = !!relStats && (relStats.activeItems > 0 || analysis.relationTypes.length > 0);
|
|
3221
3629
|
if (analysis.hasRelations) analysis.hasAnimations = true;
|
|
3222
3630
|
}
|
|
3223
3631
|
|
|
@@ -3372,8 +3780,59 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3372
3780
|
*/
|
|
3373
3781
|
generateLoadMap(input) {
|
|
3374
3782
|
const validated = LoadMapInputSchema.parse(input);
|
|
3375
|
-
const { mapId, ...
|
|
3376
|
-
|
|
3783
|
+
const { mapId, ...raw } = validated;
|
|
3784
|
+
// THIS SCHEMA AND loadMap() SHARED ALMOST NO VOCABULARY.
|
|
3785
|
+
//
|
|
3786
|
+
// loadMap(source, options) destructures projection, center, scale, rotate,
|
|
3787
|
+
// fitBounds, parent, styles, style, interactive, selectable, showOcean,
|
|
3788
|
+
// oceanColor, quality, smoothPaths and simplifyTolerance. Of the twelve
|
|
3789
|
+
// options this tool offered, SEVEN were none of those: the styling keys
|
|
3790
|
+
// live nested under `styles` and are spelled differently there, and the two
|
|
3791
|
+
// interactivity flags have other names entirely. Every one was accepted,
|
|
3792
|
+
// JSON-stringified into the call and dropped — a caller who set fillColor
|
|
3793
|
+
// got the default grey with no indication why.
|
|
3794
|
+
const { fillColor, strokeColor, strokeWidth, hoverFill, hoverStroke, enableHover, enableClick, options: extraOptions, ...passthrough } = raw;
|
|
3795
|
+
// The engine's own style vocabulary — see stylePresets in MapSystem.js.
|
|
3796
|
+
const styles = {};
|
|
3797
|
+
if (fillColor !== undefined)
|
|
3798
|
+
styles.fill = fillColor;
|
|
3799
|
+
if (strokeColor !== undefined)
|
|
3800
|
+
styles.stroke = strokeColor;
|
|
3801
|
+
// strokeWidth only survives inside `styles`; the merge overwrites a
|
|
3802
|
+
// top-level one with the quality preset's value.
|
|
3803
|
+
if (strokeWidth !== undefined)
|
|
3804
|
+
styles.strokeWidth = strokeWidth;
|
|
3805
|
+
if (hoverFill !== undefined)
|
|
3806
|
+
styles.hoverFill = hoverFill;
|
|
3807
|
+
// hoverStroke reads from mergedStyles beside hoverFill, so it belongs in
|
|
3808
|
+
// the same place. It landed late — the two MCP specs documented it for a
|
|
3809
|
+
// long time before any source file did — and an older studio merges it,
|
|
3810
|
+
// finds nothing that reads it, and leaves the outline at the base stroke.
|
|
3811
|
+
// Mapped rather than stripped so it simply starts working on such a studio
|
|
3812
|
+
// without a change here, and reported below so a caller on an older one
|
|
3813
|
+
// learns why their outline did not move.
|
|
3814
|
+
if (hoverStroke !== undefined)
|
|
3815
|
+
styles.hoverStroke = hoverStroke;
|
|
3816
|
+
// `options` was declared by the tool's description and stripped by the
|
|
3817
|
+
// schema, so `{style: 'dark'}` never reached loadMap. It carries the
|
|
3818
|
+
// options not named individually above — style presets, showOcean,
|
|
3819
|
+
// fitBounds, preserve — and is spread FIRST so an explicitly named field
|
|
3820
|
+
// still wins over the same key inside it.
|
|
3821
|
+
const engineOptions = {
|
|
3822
|
+
...(extraOptions && typeof extraOptions === 'object' ? extraOptions : {}),
|
|
3823
|
+
...passthrough,
|
|
3824
|
+
};
|
|
3825
|
+
if (Object.keys(styles).length > 0)
|
|
3826
|
+
engineOptions.styles = styles;
|
|
3827
|
+
if (enableHover !== undefined)
|
|
3828
|
+
engineOptions.interactive = enableHover;
|
|
3829
|
+
if (enableClick !== undefined)
|
|
3830
|
+
engineOptions.selectable = enableClick;
|
|
3831
|
+
const ignored = hoverStroke !== undefined ? ['hoverStroke'] : [];
|
|
3832
|
+
const optionsStr = Object.keys(engineOptions).length > 0 ? JSON.stringify(engineOptions) : '{}';
|
|
3833
|
+
const warn = ignored.length
|
|
3834
|
+
? `\n conditional: ${JSON.stringify(ignored)},\n note: 'hoverStroke needs a studio recent enough to read it; an older one ignores it and the hover outline stays the base stroke.',`
|
|
3835
|
+
: '';
|
|
3377
3836
|
return `
|
|
3378
3837
|
// Load geographic map
|
|
3379
3838
|
(async function() {
|
|
@@ -3388,7 +3847,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3388
3847
|
mapId: result.mapId || '${mapId}',
|
|
3389
3848
|
regions: result.regions?.length || 0,
|
|
3390
3849
|
bounds: result.bounds,
|
|
3391
|
-
center: result.center
|
|
3850
|
+
center: result.center,${warn}
|
|
3392
3851
|
};
|
|
3393
3852
|
} catch (error) {
|
|
3394
3853
|
return { success: false, error: error.message };
|
|
@@ -3402,7 +3861,28 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3402
3861
|
generateHighlightRegions(input) {
|
|
3403
3862
|
const validated = HighlightRegionsInputSchema.parse(input);
|
|
3404
3863
|
const regionIds = JSON.stringify(validated.regionIds);
|
|
3405
|
-
|
|
3864
|
+
// highlightRegions(ids, style) reads style.fill / .stroke / .strokeWidth.
|
|
3865
|
+
// This passed fillColor / strokeColor / strokeWidth, so the first two were
|
|
3866
|
+
// dropped and every highlight came out the default blue. A caller reaching
|
|
3867
|
+
// for `color` — the obvious name, and the one a pilot actually used — was
|
|
3868
|
+
// rejected by the schema before it got this far; it is accepted now and
|
|
3869
|
+
// means the fill.
|
|
3870
|
+
const ho = (validated.options ?? {});
|
|
3871
|
+
const style = {};
|
|
3872
|
+
const fill = ho.color ?? ho.fillColor ?? ho.fill;
|
|
3873
|
+
if (fill !== undefined)
|
|
3874
|
+
style.fill = fill;
|
|
3875
|
+
const stroke = ho.strokeColor ?? ho.stroke;
|
|
3876
|
+
if (stroke !== undefined)
|
|
3877
|
+
style.stroke = stroke;
|
|
3878
|
+
if (ho.strokeWidth !== undefined)
|
|
3879
|
+
style.strokeWidth = ho.strokeWidth;
|
|
3880
|
+
for (const [k, v] of Object.entries(ho)) {
|
|
3881
|
+
if (!['color', 'fillColor', 'fill', 'strokeColor', 'stroke', 'strokeWidth'].includes(k) && v !== undefined) {
|
|
3882
|
+
style[k] = v;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
const optionsStr = JSON.stringify(style);
|
|
3406
3886
|
return `
|
|
3407
3887
|
// Highlight map regions
|
|
3408
3888
|
(function() {
|
|
@@ -3447,7 +3927,45 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3447
3927
|
generateApplyDataColors(input) {
|
|
3448
3928
|
const validated = ApplyDataColorsInputSchema.parse(input);
|
|
3449
3929
|
const dataStr = JSON.stringify(validated.data);
|
|
3450
|
-
|
|
3930
|
+
// THE ENGINE TAKES A COLOUR RAMP, NOT THE NAME OF ONE.
|
|
3931
|
+
//
|
|
3932
|
+
// applyDataColors destructures `colorScale = ['#f7fbff', '#08519c']` — two
|
|
3933
|
+
// endpoints it interpolates between — plus `domain` and `legend`. This tool
|
|
3934
|
+
// published a preset NAME ('greens'), which the engine then indexed as an
|
|
3935
|
+
// array: colorScale[0] of a string is 'g', and every region came out
|
|
3936
|
+
// near-black. It also published minValue/maxValue/showLegend, none of which
|
|
3937
|
+
// the engine reads.
|
|
3938
|
+
//
|
|
3939
|
+
// Translated here rather than in the schema: the names are the friendlier
|
|
3940
|
+
// surface and this is ordinary cross-repo vocabulary drift, which is what
|
|
3941
|
+
// the emitter exists to absorb.
|
|
3942
|
+
const RAMPS = {
|
|
3943
|
+
blues: ['#f7fbff', '#08519c'],
|
|
3944
|
+
greens: ['#f7fcf5', '#006d2c'],
|
|
3945
|
+
reds: ['#fff5f0', '#a50f15'],
|
|
3946
|
+
oranges: ['#fff5eb', '#a63603'],
|
|
3947
|
+
purples: ['#fcfbfd', '#54278f'],
|
|
3948
|
+
heat: ['#ffffb2', '#bd0026'],
|
|
3949
|
+
};
|
|
3950
|
+
const o = validated.options ?? {};
|
|
3951
|
+
const engineOptions = {};
|
|
3952
|
+
if (o.colorScale)
|
|
3953
|
+
engineOptions.colorScale = RAMPS[o.colorScale] ?? RAMPS.blues;
|
|
3954
|
+
if (o.minValue !== undefined || o.maxValue !== undefined) {
|
|
3955
|
+
const values = Object.values(validated.data);
|
|
3956
|
+
engineOptions.domain = [
|
|
3957
|
+
o.minValue ?? Math.min(...values),
|
|
3958
|
+
o.maxValue ?? Math.max(...values),
|
|
3959
|
+
];
|
|
3960
|
+
}
|
|
3961
|
+
if (o.showLegend !== undefined)
|
|
3962
|
+
engineOptions.legend = o.showLegend;
|
|
3963
|
+
for (const [k, v] of Object.entries(o)) {
|
|
3964
|
+
if (!['colorScale', 'minValue', 'maxValue', 'showLegend'].includes(k) && v !== undefined) {
|
|
3965
|
+
engineOptions[k] = v;
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
const optionsStr = JSON.stringify(engineOptions);
|
|
3451
3969
|
return `
|
|
3452
3970
|
// Apply choropleth data colors
|
|
3453
3971
|
(function() {
|
|
@@ -3514,8 +4032,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3514
4032
|
}
|
|
3515
4033
|
|
|
3516
4034
|
try {
|
|
3517
|
-
|
|
3518
|
-
|
|
4035
|
+
// addLabels() has never existed. addRegionLabel(regionId, options) is the
|
|
4036
|
+
// engine's own per-region call, and this tool's input is a list of regions,
|
|
4037
|
+
// so the loop IS the mapping — applyCustomLabels takes a
|
|
4038
|
+
// {regionId: text} map instead, which is a different question.
|
|
4039
|
+
const _labelled = [];
|
|
4040
|
+
const _missed = [];
|
|
4041
|
+
for (const _r of ${regionsStr}) {
|
|
4042
|
+
const _item = app.mapSystem.addRegionLabel(_r, ${optionsStr});
|
|
4043
|
+
(_item ? _labelled : _missed).push(_r);
|
|
4044
|
+
}
|
|
4045
|
+
return { success: _labelled.length > 0, labelled: _labelled, count: _labelled.length,
|
|
4046
|
+
...(_missed.length ? { notLabelled: _missed, note: 'no region on this map matched these ids — check get_highlighted_map_regions or the map you loaded.' } : {}) };
|
|
3519
4047
|
} catch (error) {
|
|
3520
4048
|
return { success: false, error: error.message };
|
|
3521
4049
|
}
|
|
@@ -3540,8 +4068,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3540
4068
|
}
|
|
3541
4069
|
|
|
3542
4070
|
try {
|
|
3543
|
-
|
|
3544
|
-
|
|
4071
|
+
// panTo() has never existed on any studio, and there is no lat/lon pan for
|
|
4072
|
+
// a flat map to route this to. Refused by NAME with the calls that do work,
|
|
4073
|
+
// rather than letting the caller take an undefined-is-not-a-function.
|
|
4074
|
+
return { success: false, error: 'pan_map is not supported: the engine has no lat/lon pan. '
|
|
4075
|
+
+ 'On a globe, rotateGlobeTo brings a coordinate to the front; on a flat map, zoom to a REGION '
|
|
4076
|
+
+ '(zoomToRegion) or call resetView for the whole view. This tool accepted coordinates and did '
|
|
4077
|
+
+ 'nothing for as long as it existed.' };
|
|
3545
4078
|
} catch (error) {
|
|
3546
4079
|
return { success: false, error: error.message };
|
|
3547
4080
|
}
|
|
@@ -3566,8 +4099,11 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3566
4099
|
}
|
|
3567
4100
|
|
|
3568
4101
|
try {
|
|
3569
|
-
|
|
3570
|
-
|
|
4102
|
+
// zoomTo(level) has never existed. The engine zooms to a REGION, not to a
|
|
4103
|
+
// numeric level, so there is nothing to convert a level into.
|
|
4104
|
+
return { success: false, error: 'zoom_map takes a numeric level and the engine has no such call: it zooms to a REGION. '
|
|
4105
|
+
+ 'Use zoomToRegion with a region id, or resetView for the full view. A level was accepted here and did '
|
|
4106
|
+
+ 'nothing for as long as this tool existed.' };
|
|
3571
4107
|
} catch (error) {
|
|
3572
4108
|
return { success: false, error: error.message };
|
|
3573
4109
|
}
|
|
@@ -3586,8 +4122,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3586
4122
|
}
|
|
3587
4123
|
|
|
3588
4124
|
try {
|
|
3589
|
-
|
|
3590
|
-
|
|
4125
|
+
// exportMap() has never existed. exportGeoJSON() is the real one, and it
|
|
4126
|
+
// returns the geometry rather than a "configuration" — named accordingly
|
|
4127
|
+
// instead of being spread into a shape the caller cannot rely on.
|
|
4128
|
+
if (typeof app.mapSystem.exportGeoJSON !== 'function') {
|
|
4129
|
+
return { success: false, error: 'this studio cannot export map data — app.mapSystem.exportGeoJSON is unavailable.' };
|
|
4130
|
+
}
|
|
4131
|
+
const geojson = app.mapSystem.exportGeoJSON();
|
|
4132
|
+
const info = typeof app.mapSystem.getMapSourceInfo === 'function' ? app.mapSystem.getMapSourceInfo() : null;
|
|
4133
|
+
return { success: true, geojson, ...(info ? { source: info } : {}) };
|
|
3591
4134
|
} catch (error) {
|
|
3592
4135
|
return { success: false, error: error.message };
|
|
3593
4136
|
}
|
|
@@ -3654,7 +4197,16 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3654
4197
|
}
|
|
3655
4198
|
|
|
3656
4199
|
try {
|
|
3657
|
-
|
|
4200
|
+
// getRegionAtPoint() has never existed; the engine exposes no hit test.
|
|
4201
|
+
// canvasToGeo turns a point into COORDINATES, which is a different answer,
|
|
4202
|
+
// so it is offered rather than quietly substituted.
|
|
4203
|
+
if (typeof app.mapSystem.canvasToGeo === 'function') {
|
|
4204
|
+
const coord = app.mapSystem.canvasToGeo(${validated.x}, ${validated.y});
|
|
4205
|
+
return { success: false, coordinate: coord,
|
|
4206
|
+
error: 'the engine has no region hit test. That point is at ' + JSON.stringify(coord)
|
|
4207
|
+
+ ' — find regions by id with get_highlighted_map_regions or getSelectedRegions.' };
|
|
4208
|
+
}
|
|
4209
|
+
const region = null;
|
|
3658
4210
|
if (region) {
|
|
3659
4211
|
return { success: true, regionId: region.id, regionName: region.name, properties: region.properties };
|
|
3660
4212
|
}
|
|
@@ -3708,7 +4260,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3708
4260
|
}
|
|
3709
4261
|
|
|
3710
4262
|
try {
|
|
3711
|
-
const result = app.mapSystem.
|
|
4263
|
+
const result = app.mapSystem.animateRegionsWave({
|
|
3712
4264
|
duration: ${validated.duration || 10},
|
|
3713
4265
|
loop: ${validated.loop !== false},
|
|
3714
4266
|
colors: ${colorsStr},
|
|
@@ -4406,6 +4958,17 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
4406
4958
|
const keyframesStr = validated.keyframes ? JSON.stringify(validated.keyframes) : 'null';
|
|
4407
4959
|
const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
|
|
4408
4960
|
const preset = validated.preset || validated.maskType || 'wipeLeft';
|
|
4961
|
+
// MODE 3 PASSED AN ANIMATION PRESET WHERE A MASK SHAPE GOES.
|
|
4962
|
+
//
|
|
4963
|
+
// applyCustomMask(item, maskType, keyframes, options) wants a SHAPE —
|
|
4964
|
+
// 'rectangle', 'circle', 'star'. With preset + keyframes this handed it
|
|
4965
|
+
// 'iris' or 'wipeLeft', which is an animation name, so the shape was
|
|
4966
|
+
// whatever the engine falls back to and the keyframes drove the wrong
|
|
4967
|
+
// geometry. maskType is the field that carries the shape, and it is only
|
|
4968
|
+
// used as the preset above when no preset was given at all.
|
|
4969
|
+
const customShape = validated.maskType && validated.maskType !== preset
|
|
4970
|
+
? validated.maskType
|
|
4971
|
+
: 'rectangle';
|
|
4409
4972
|
// Per-preset options. Custom keyframes are an animated-mask extension —
|
|
4410
4973
|
// when supplied, fall through to maskingSystem.applyCustomMask instead.
|
|
4411
4974
|
return `
|
|
@@ -4421,7 +4984,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
4421
4984
|
const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
|
|
4422
4985
|
let maskedGroup;
|
|
4423
4986
|
if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
|
|
4424
|
-
maskedGroup = app.maskingSystem.applyCustomMask(item, '${
|
|
4987
|
+
maskedGroup = app.maskingSystem.applyCustomMask(item, '${customShape}', ${keyframesStr}, mergedOpts);
|
|
4425
4988
|
} else {
|
|
4426
4989
|
maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
|
|
4427
4990
|
}
|
|
@@ -4641,7 +5204,51 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
4641
5204
|
}
|
|
4642
5205
|
try {
|
|
4643
5206
|
await app.templateManager.loadTemplate('${templateId}', true);
|
|
4644
|
-
|
|
5207
|
+
|
|
5208
|
+
// DID THE CLIPPED CHARACTER PARTS SURVIVE?
|
|
5209
|
+
//
|
|
5210
|
+
// A clipped character group is saved with its nested texture and mask in
|
|
5211
|
+
// paperJSON. A studio whose restore predates the reader for that key
|
|
5212
|
+
// rebuilds it through the generic group branch instead — an empty,
|
|
5213
|
+
// unclipped group — so the part is written to the template correctly and
|
|
5214
|
+
// dropped on the way back in, with nothing raised.
|
|
5215
|
+
//
|
|
5216
|
+
// Checked by BEHAVIOUR, not by version: the template says how many clipped
|
|
5217
|
+
// groups it carries, the canvas says how many came back. An engine that
|
|
5218
|
+
// restores them makes this condition unreachable, so it needs no removing
|
|
5219
|
+
// later; an older studio is exactly where it still matters, and this
|
|
5220
|
+
// package ships independently of the studio it drives.
|
|
5221
|
+
//
|
|
5222
|
+
// THE WARNING MATTERS MORE THAN THE LOAD. Loading is harmless and
|
|
5223
|
+
// reversible — the file on disk still holds the paperJSON, so the same
|
|
5224
|
+
// template reloads intact on a studio that can read it. Re-SAVING from
|
|
5225
|
+
// this canvas is not: the exporter would serialise the empty group it can
|
|
5226
|
+
// see, and the texture and mask would leave the file for good.
|
|
5227
|
+
let fidelity = {};
|
|
5228
|
+
try {
|
|
5229
|
+
const tpl = (app.templateManager.getAllTemplates() || [])
|
|
5230
|
+
.find(function(t) { return t && (t.id === '${templateId}' || t.templateId === '${templateId}'); });
|
|
5231
|
+
const items = tpl && tpl.data && Array.isArray(tpl.data.items) ? tpl.data.items : null;
|
|
5232
|
+
const declared = items ? items.filter(function(i) { return i && i.type === 'group' && i.paperJSON; }).length : 0;
|
|
5233
|
+
if (declared > 0 && app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
|
|
5234
|
+
const clipped = (app.itemRegistry.getAll() || []).filter(function(e) {
|
|
5235
|
+
const it = e && (e.item || e);
|
|
5236
|
+
return it && it.className === 'Group' && it.clipped === true;
|
|
5237
|
+
}).length;
|
|
5238
|
+
if (clipped === 0) {
|
|
5239
|
+
fidelity = {
|
|
5240
|
+
clippedPartsLost: declared,
|
|
5241
|
+
warning: 'This template carries ' + declared + ' clipped character part(s) with a nested texture and mask, '
|
|
5242
|
+
+ 'and this studio restored none of them — they are on the canvas as empty groups. '
|
|
5243
|
+
+ 'DO NOT SAVE THIS CANVAS BACK OVER THE TEMPLATE: the export would write the empty groups and the '
|
|
5244
|
+
+ 'texture would be lost from the file permanently. The template itself is still intact; update the '
|
|
5245
|
+
+ 'studio and load it again.',
|
|
5246
|
+
};
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
} catch (e) { /* a check that cannot run must not fail the load */ }
|
|
5250
|
+
|
|
5251
|
+
return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.', ...fidelity };
|
|
4645
5252
|
} catch (e) {
|
|
4646
5253
|
return { error: 'Failed to apply template: ' + e.message };
|
|
4647
5254
|
}
|
|
@@ -4681,13 +5288,25 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
4681
5288
|
// the network again — so a host that allows fetch but blocks the image
|
|
4682
5289
|
// load (CORS on <img>, a tainted-canvas refusal) now works too.
|
|
4683
5290
|
let src = '${url}';
|
|
5291
|
+
// A STAGED KEY, not the bytes. The handler writes anything over ~64KB to
|
|
5292
|
+
// window.__ppStage and passes the key instead, because the governor's
|
|
5293
|
+
// loop-guard transform bails on a code string that large and takes the
|
|
5294
|
+
// run's return value with it. See ExecuteCodeOptions.stage.
|
|
5295
|
+
if (src.indexOf('__ppStage:') === 0) {
|
|
5296
|
+
const _key = src.slice('__ppStage:'.length);
|
|
5297
|
+
const _staged = window.__ppStage && window.__ppStage[_key];
|
|
5298
|
+
if (!_staged) {
|
|
5299
|
+
return { success: false, error: 'the image bytes were staged as ' + _key + ' and are not on the page — this is a bug in the MCP server, not your call.' };
|
|
5300
|
+
}
|
|
5301
|
+
src = _staged;
|
|
5302
|
+
}
|
|
4684
5303
|
if (!src.startsWith('data:')) {
|
|
4685
5304
|
let res;
|
|
4686
5305
|
try {
|
|
4687
5306
|
res = await fetch(src);
|
|
4688
5307
|
} catch (netErr) {
|
|
4689
5308
|
return { success: false, error: 'could not reach ' + src + ' — ' + (netErr && netErr.message ? netErr.message : 'network request failed')
|
|
4690
|
-
+ '.
|
|
5309
|
+
+ '. The failure is in the browser network stack: check the host resolves, the port is open, and that any proxy passes plain HTTP as well as HTTPS CONNECT.' };
|
|
4691
5310
|
}
|
|
4692
5311
|
if (!res.ok) {
|
|
4693
5312
|
return { success: false, error: 'the server refused ' + src + ' — HTTP ' + res.status + ' ' + (res.statusText || ''), status: res.status };
|
|
@@ -5537,7 +6156,7 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
5537
6156
|
// Get history state
|
|
5538
6157
|
(function() {
|
|
5539
6158
|
${guard}
|
|
5540
|
-
const state = app.historyManager.
|
|
6159
|
+
const state = app.historyManager.getInfo();
|
|
5541
6160
|
return { success: true, action: 'get_state', ...state };
|
|
5542
6161
|
})();`.trim();
|
|
5543
6162
|
default:
|
|
@@ -5614,38 +6233,45 @@ ${resolve}
|
|
|
5614
6233
|
}
|
|
5615
6234
|
}
|
|
5616
6235
|
generateLasso(input) {
|
|
5617
|
-
|
|
6236
|
+
// LASSO IS A MOUSE TOOL. IT HAS NO HEADLESS FORM.
|
|
6237
|
+
//
|
|
6238
|
+
// These called app.imageTools.activateLasso and .applyLasso, neither of
|
|
6239
|
+
// which exists — imageTools is an ImageToolsManager and the lasso lives on
|
|
6240
|
+
// app.lassoTool. `activate` is real there, but the extraction is driven by
|
|
6241
|
+
// startStroke / continueStroke / endStroke: a freehand path the USER draws.
|
|
6242
|
+
// There is no applyLasso to finish it, so wiring activate alone would leave
|
|
6243
|
+
// a caller in a mode it cannot get out of or complete.
|
|
6244
|
+
//
|
|
6245
|
+
// Refused by name, with the tools that do the same job without a pointer.
|
|
6246
|
+
const why = 'the lasso is an interactive mouse tool (app.lassoTool: activate, then startStroke/continueStroke/endStroke as the user draws). '
|
|
6247
|
+
+ 'There is no headless call that completes an extraction, so this tool could never work over MCP. '
|
|
6248
|
+
+ 'To cut a region without a pointer, use pinepaper_extract_object (detects and extracts by description), '
|
|
6249
|
+
+ 'or pinepaper_cutout_style / the mask actions on pinepaper_image_tools to shape an existing raster.';
|
|
5618
6250
|
switch (input.action) {
|
|
5619
6251
|
case 'activate':
|
|
5620
|
-
return `
|
|
5621
|
-
// Activate lasso selection
|
|
5622
|
-
(async function() {
|
|
5623
|
-
${guard}
|
|
5624
|
-
try {
|
|
5625
|
-
await app.imageTools.activateLasso(${JSON.stringify(input.itemId || '')});
|
|
5626
|
-
return { success: true, action: 'activate', itemId: ${JSON.stringify(input.itemId || '')} };
|
|
5627
|
-
} catch (e) {
|
|
5628
|
-
return { error: 'Failed to activate lasso: ' + e.message };
|
|
5629
|
-
}
|
|
5630
|
-
})();`.trim();
|
|
5631
6252
|
case 'apply':
|
|
5632
6253
|
return `
|
|
5633
|
-
//
|
|
5634
|
-
(
|
|
5635
|
-
${guard}
|
|
5636
|
-
try {
|
|
5637
|
-
const result = await app.imageTools.applyLasso();
|
|
5638
|
-
return { success: true, action: 'apply' };
|
|
5639
|
-
} catch (e) {
|
|
5640
|
-
return { error: 'Failed to apply lasso: ' + e.message };
|
|
5641
|
-
}
|
|
5642
|
-
})();`.trim();
|
|
6254
|
+
// Lasso — refused, rather than calling into a method that does not exist
|
|
6255
|
+
({ success: false, action: ${JSON.stringify(input.action)}, error: ${JSON.stringify(why)} });`.trim();
|
|
5643
6256
|
default:
|
|
5644
6257
|
return `(function() { return { error: 'Unknown lasso action: ${input.action}' }; })();`;
|
|
5645
6258
|
}
|
|
5646
6259
|
}
|
|
5647
6260
|
generateCutoutStyle(input) {
|
|
5648
|
-
|
|
6261
|
+
// THE FACADE WAS THE WRONG ONE ENTIRELY.
|
|
6262
|
+
//
|
|
6263
|
+
// These called app.imageTools.applyCutoutStyle / .getCutoutStyles, and
|
|
6264
|
+
// neither exists anywhere in the engine — imageTools is an
|
|
6265
|
+
// ImageToolsManager and knows nothing about cutout presets. Reported from
|
|
6266
|
+
// production as "apply returns success:true with the error nested inside",
|
|
6267
|
+
// which is what awaiting a call on undefined inside a try looks like.
|
|
6268
|
+
//
|
|
6269
|
+
// The real surface is app.cutoutStyles (a CutoutStylePresets), with
|
|
6270
|
+
// applyPreset(item, name, options) and getPresets(). The parity guard
|
|
6271
|
+
// could not see this: readFacades resolved a class by CONVENTION —
|
|
6272
|
+
// imageTools -> ImageTools, which is not the class's name — so the facade
|
|
6273
|
+
// was never mapped, and an unmapped facade is not checked.
|
|
6274
|
+
const guard = `if (!app.cutoutStyles) return { success: false, error: 'cutout styles are unavailable on this studio (app.cutoutStyles is absent) — update PinePaper Studio.' };`;
|
|
5649
6275
|
switch (input.action) {
|
|
5650
6276
|
case 'apply':
|
|
5651
6277
|
return `
|
|
@@ -5653,7 +6279,11 @@ ${resolve}
|
|
|
5653
6279
|
(async function() {
|
|
5654
6280
|
${guard}
|
|
5655
6281
|
try {
|
|
5656
|
-
const
|
|
6282
|
+
const target = app.getItemById && app.getItemById(${JSON.stringify(input.itemId || '')});
|
|
6283
|
+
if (!target) {
|
|
6284
|
+
return { success: false, action: 'apply', error: 'no item ' + ${JSON.stringify(input.itemId || '')} + ' — applyPreset takes a live item, so the id has to resolve first.' };
|
|
6285
|
+
}
|
|
6286
|
+
const result = await app.cutoutStyles.applyPreset(target, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
|
|
5657
6287
|
// An unknown preset returns the very item it was given, unchanged — so a
|
|
5658
6288
|
// truthy result proves nothing. The engine records the refusal on it.
|
|
5659
6289
|
const cutRej = result && result.data && result.data.cutoutStyleRejected;
|
|
@@ -5670,7 +6300,9 @@ ${resolve}
|
|
|
5670
6300
|
// List available cutout styles
|
|
5671
6301
|
(function() {
|
|
5672
6302
|
${guard}
|
|
5673
|
-
const styles = app.
|
|
6303
|
+
const styles = typeof app.getCutoutStylePresets === 'function'
|
|
6304
|
+
? app.getCutoutStylePresets()
|
|
6305
|
+
: app.cutoutStyles.getPresets();
|
|
5674
6306
|
return { success: true, action: 'list', styles };
|
|
5675
6307
|
})();`.trim();
|
|
5676
6308
|
default:
|
|
@@ -5711,14 +6343,25 @@ ${resolve}
|
|
|
5711
6343
|
|
|
5712
6344
|
const precomp = app.createPrecomp(items, opts);
|
|
5713
6345
|
if (!precomp) return { success: false, error: 'createPrecomp returned nothing' };
|
|
5714
|
-
|
|
6346
|
+
// STRINGIFIED, because the schema declares precompId as a string and the
|
|
6347
|
+
// engine mints a NUMBER. Returning the number meant the id could not be
|
|
6348
|
+
// round-tripped: pass it back to add/remove and zod rejects it before the
|
|
6349
|
+
// call is made.
|
|
6350
|
+
const _pid = precomp && precomp.id !== undefined ? precomp.id : precomp;
|
|
6351
|
+
return { success: true, action: 'create', precompId: String(_pid), itemCount: items.length, name: ${JSON.stringify(input.name || '')} };
|
|
5715
6352
|
})();`.trim();
|
|
5716
6353
|
}
|
|
5717
6354
|
case 'add':
|
|
5718
6355
|
return `
|
|
5719
6356
|
// Add item to precomp
|
|
5720
6357
|
(function() {
|
|
5721
|
-
|
|
6358
|
+
// addToPrecomp answers falsy when the precomp or the item is not found, and
|
|
6359
|
+
// this reported success regardless — the same silent-success shape as an
|
|
6360
|
+
// unknown itemId elsewhere.
|
|
6361
|
+
const _ok = app.addToPrecomp(${JSON.stringify(input.precompId || '')}, ${JSON.stringify(input.itemId || '')});
|
|
6362
|
+
if (_ok === false || _ok === null || _ok === undefined) {
|
|
6363
|
+
return { success: false, action: 'add', error: 'nothing was added — check the precompId (create returns it as a string) and the itemId with pinepaper_get_items.' };
|
|
6364
|
+
}
|
|
5722
6365
|
return { success: true, action: 'add', precompId: ${JSON.stringify(input.precompId || '')}, itemId: ${JSON.stringify(input.itemId || '')} };
|
|
5723
6366
|
})();`.trim();
|
|
5724
6367
|
case 'remove':
|
|
@@ -6237,7 +6880,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
6237
6880
|
// Trigger interaction action
|
|
6238
6881
|
(function() {
|
|
6239
6882
|
${guard}
|
|
6240
|
-
app.interactionSystem.
|
|
6883
|
+
app.interactionSystem.executeAction('${input.actionType}', ${params});
|
|
6241
6884
|
return { success: true, action: 'trigger_action', actionType: '${input.actionType}' };
|
|
6242
6885
|
})();`.trim();
|
|
6243
6886
|
}
|
|
@@ -7133,6 +7776,9 @@ ${guard}
|
|
|
7133
7776
|
...(r.note ? { note: r.note } : {}) };
|
|
7134
7777
|
})();`.trim();
|
|
7135
7778
|
}
|
|
7779
|
+
// 'list' is the spelling pinepaper_text_effect uses for the same action,
|
|
7780
|
+
// accepted here so moving between the two tools does not dead-end.
|
|
7781
|
+
case 'list':
|
|
7136
7782
|
case 'list_styles': {
|
|
7137
7783
|
return `
|
|
7138
7784
|
// Text styles + palettes + font axes — the picker surface
|
|
@@ -7368,9 +8014,42 @@ ${guard}
|
|
|
7368
8014
|
// r.items are live Paper objects — NOT serializable, deliberately dropped.
|
|
7369
8015
|
// The source item id is GONE unless keepSource was set, so r.ids is now the
|
|
7370
8016
|
// caller's entire handle set; returning it is what makes the tool usable.
|
|
8017
|
+
// EVERY EFFECT PLAYS FROM t=0, so in a multi-shot video they all fire on the
|
|
8018
|
+
// opening frame and only the first title reads. The engine has no start
|
|
8019
|
+
// delay, so the keyframes it just wrote are shifted here — the same thing a
|
|
8020
|
+
// caller was doing by hand through execute_custom_code.
|
|
8021
|
+
//
|
|
8022
|
+
// Shifted, then held: without an explicit invisible keyframe at 0 the
|
|
8023
|
+
// characters sit in their FIRST pose until the effect starts, which for most
|
|
8024
|
+
// of the 37 is a scatter that looks like a bug. hideAfter closes the other
|
|
8025
|
+
// end, because they otherwise survive to the end of the clip.
|
|
8026
|
+
const _startAt = ${S(input.startAt ?? 0)};
|
|
8027
|
+
const _hideAfter = ${S(input.hideAfter ?? null)};
|
|
8028
|
+
let _shifted = 0;
|
|
8029
|
+
if ((_startAt > 0 || _hideAfter) && Array.isArray(r.ids)) {
|
|
8030
|
+
for (const _id of r.ids) {
|
|
8031
|
+
const _it = app.getItemById && app.getItemById(_id);
|
|
8032
|
+
const _kfs = _it && _it.data && _it.data.keyframes;
|
|
8033
|
+
if (!Array.isArray(_kfs) || _kfs.length === 0) continue;
|
|
8034
|
+
if (_startAt > 0) {
|
|
8035
|
+
for (const _k of _kfs) _k.time += _startAt;
|
|
8036
|
+
_kfs.unshift({ time: 0, properties: { opacity: 0 }, easing: 'linear' });
|
|
8037
|
+
_kfs.splice(1, 0, { time: Math.max(0, _startAt - 0.001), properties: { opacity: 0 }, easing: 'linear' });
|
|
8038
|
+
}
|
|
8039
|
+
if (_hideAfter) {
|
|
8040
|
+
_kfs.push({ time: Math.max(0, _hideAfter - 0.001), properties: { opacity: 1 }, easing: 'linear' });
|
|
8041
|
+
_kfs.push({ time: _hideAfter, properties: { opacity: 0 }, easing: 'linear' });
|
|
8042
|
+
}
|
|
8043
|
+
_kfs.sort((a, b) => a.time - b.time);
|
|
8044
|
+
_shifted++;
|
|
8045
|
+
}
|
|
8046
|
+
}
|
|
7371
8047
|
return {
|
|
7372
8048
|
success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
|
|
7373
|
-
duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
|
|
8049
|
+
duration: (r.duration || 0) + _startAt, sourceRemoved: ${S(input.keepSource !== true)},
|
|
8050
|
+
...(_startAt > 0 ? { startAt: _startAt } : {}),
|
|
8051
|
+
...(_hideAfter ? { hideAfter: _hideAfter } : {}),
|
|
8052
|
+
...((_startAt > 0 || _hideAfter) ? { retimed: _shifted } : {}),
|
|
7374
8053
|
};
|
|
7375
8054
|
})();`.trim();
|
|
7376
8055
|
}
|
|
@@ -7493,7 +8172,7 @@ ${guard}
|
|
|
7493
8172
|
// World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
|
|
7494
8173
|
(async function() {
|
|
7495
8174
|
if (typeof app.createWorld3D !== 'function') { return { success: false, error: 'app.createWorld3D unavailable — update FxTool to a world3d-capable build' }; }
|
|
7496
|
-
await app.createWorld3D(${S(input.spec ?? 'forest')}, ${opts});
|
|
8175
|
+
await app.createWorld3D(${S(world3dColors(input.spec ?? 'forest'))}, ${opts});
|
|
7497
8176
|
// describe() is the world's own parameter schema — return the preset list +
|
|
7498
8177
|
// top-level keys so the agent knows what configure can touch, without the
|
|
7499
8178
|
// full multi-KB schema on every create.
|
|
@@ -7514,7 +8193,7 @@ ${guard}
|
|
|
7514
8193
|
(function() {
|
|
7515
8194
|
if (typeof app.configureWorld3D !== 'function') { return { success: false, error: 'app.configureWorld3D unavailable — update FxTool' }; }
|
|
7516
8195
|
${needWorld}
|
|
7517
|
-
const r = app.configureWorld3D(${S(input.patch)});
|
|
8196
|
+
const r = app.configureWorld3D(${S(world3dColors(input.patch))});
|
|
7518
8197
|
// The validator names the right key on a wrong one — forward it verbatim.
|
|
7519
8198
|
return r && r.ok ? { success: true, action: 'configure' } : { success: false, error: (r && r.error) || 'configure failed' };
|
|
7520
8199
|
})();`.trim();
|
|
@@ -7570,7 +8249,7 @@ ${needWorld}
|
|
|
7570
8249
|
// World3D: place an object (y defaults to sitting on the terrain)
|
|
7571
8250
|
(function() {
|
|
7572
8251
|
${needWorld}
|
|
7573
|
-
const id = app.addWorldObject(${S(input.object)});
|
|
8252
|
+
const id = app.addWorldObject(${S(world3dColors(input.object))});
|
|
7574
8253
|
return id ? { success: true, objectId: id } : { success: false, error: 'object not added' };
|
|
7575
8254
|
})();`.trim();
|
|
7576
8255
|
case 'remove_object':
|
|
@@ -7935,6 +8614,40 @@ ${needWorld}
|
|
|
7935
8614
|
for (const [k, v] of Object.entries(rest))
|
|
7936
8615
|
if (v !== undefined)
|
|
7937
8616
|
opts[k] = v;
|
|
8617
|
+
// SPREAD-THROUGH IS NOT THE SAME AS AGREEING.
|
|
8618
|
+
//
|
|
8619
|
+
// Everything above this line reaches app.stickFigure verbatim, which made
|
|
8620
|
+
// the schema look consumed to every guard we have while four fields meant
|
|
8621
|
+
// something the engine does not. The engine compares propSide against
|
|
8622
|
+
// 'L' | 'R', so 'right' — which the schema declared and advertised —
|
|
8623
|
+
// matched neither arm of that test and every prop went to the LEFT hand.
|
|
8624
|
+
// Nothing failed; the figure just held it in the wrong hand, forever.
|
|
8625
|
+
if (action === 'figure') {
|
|
8626
|
+
const side = typeof opts.propSide === 'string' ? opts.propSide : undefined;
|
|
8627
|
+
if (side)
|
|
8628
|
+
opts.propSide = side.toUpperCase().startsWith('R') ? 'R' : 'L';
|
|
8629
|
+
// The engine iterates opts.walk, so `true` — the form the schema itself
|
|
8630
|
+
// advertised as "the default" — walked exactly nowhere.
|
|
8631
|
+
if (opts.walk === true)
|
|
8632
|
+
opts.walk = [{ seconds: 1.2, from: 0 }];
|
|
8633
|
+
else if (opts.walk === false)
|
|
8634
|
+
delete opts.walk;
|
|
8635
|
+
// `facing` is read for chairs and for nothing else. Passing it here
|
|
8636
|
+
// would be accepted and ignored, which is the thing being fixed, so it
|
|
8637
|
+
// is refused by name instead — the add_ports precedent.
|
|
8638
|
+
if (opts.facing !== undefined) {
|
|
8639
|
+
// Built through S() rather than interpolated: the message names two
|
|
8640
|
+
// quoted values, and hand-escaping them into a single-quoted emitted
|
|
8641
|
+
// string produced a syntax error the first time.
|
|
8642
|
+
const msg = `facing is a property of a chair, not of a figure — app.stickFigure `
|
|
8643
|
+
+ `never reads it, so ${String(opts.facing)} would have been accepted and ignored. `
|
|
8644
|
+
+ `A figure faces the way it travels, so give it travel, or a pose that turns it. `
|
|
8645
|
+
+ `facing belongs on action "set" with kind "chair".`;
|
|
8646
|
+
return `
|
|
8647
|
+
// Stick: figure — refused, rather than accepted and ignored
|
|
8648
|
+
({ success: false, error: ${S(msg)} });`.trim();
|
|
8649
|
+
}
|
|
8650
|
+
}
|
|
7938
8651
|
const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
|
|
7939
8652
|
return `
|
|
7940
8653
|
// Stick: ${action}
|
|
@@ -8073,6 +8786,24 @@ ${guard('exportBVH')}
|
|
|
8073
8786
|
${guard('exportPNGSequence')}
|
|
8074
8787
|
const out = await app.exportPNGSequence(${opts});
|
|
8075
8788
|
if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
|
|
8789
|
+
// A BLOB SERIALISES TO {}. exporter.export() answers a Blob, and returning it
|
|
8790
|
+
// raw produced a success whose result was an empty object — which reads as
|
|
8791
|
+
// "it worked and gave me nothing" rather than "the bytes could not cross
|
|
8792
|
+
// the bridge". Structured clone drops a Blob's
|
|
8793
|
+
// contents; only a string survives.
|
|
8794
|
+
if (typeof Blob !== 'undefined' && out instanceof Blob) {
|
|
8795
|
+
const dataUrl = await new Promise(function(res, rej) {
|
|
8796
|
+
const fr = new FileReader();
|
|
8797
|
+
fr.onload = function() { res(fr.result); };
|
|
8798
|
+
fr.onerror = function() { rej(new Error('could not read the PNG sequence')); };
|
|
8799
|
+
fr.readAsDataURL(out);
|
|
8800
|
+
});
|
|
8801
|
+
return { success: true, format: 'png-sequence', data: dataUrl, mimeType: out.type || 'application/zip', size: out.size };
|
|
8802
|
+
}
|
|
8803
|
+
if (Array.isArray(out)) {
|
|
8804
|
+
return { success: true, format: 'png-sequence', frames: out.length,
|
|
8805
|
+
note: 'the frames stayed in the page — pass download: true to write them out.' };
|
|
8806
|
+
}
|
|
8076
8807
|
return { success: true, format: 'png-sequence', result: out };
|
|
8077
8808
|
})();`.trim();
|
|
8078
8809
|
}
|
|
@@ -8217,15 +8948,27 @@ ${guard('renderSoundtrackWav')}
|
|
|
8217
8948
|
};
|
|
8218
8949
|
})();`.trim();
|
|
8219
8950
|
}
|
|
8220
|
-
case 'create':
|
|
8951
|
+
case 'create': {
|
|
8952
|
+
const v = (input.visual ?? {});
|
|
8953
|
+
const { startTime, duration, ...visualOnly } = v;
|
|
8954
|
+
const placement = (startTime !== undefined || duration !== undefined)
|
|
8955
|
+
? { ...(startTime !== undefined ? { startTime } : {}), ...(duration !== undefined ? { duration } : {}) }
|
|
8956
|
+
: null;
|
|
8221
8957
|
return `
|
|
8222
8958
|
// Sound: draw it AS a waveform path — the item and the sound are one thing
|
|
8223
8959
|
(function() {
|
|
8224
8960
|
${guard('createSound')}
|
|
8225
|
-
const item = app.createSound(${S(input.spec ?? {})}, ${S(
|
|
8961
|
+
const item = app.createSound(${S(input.spec ?? {})}, ${S(visualOnly)});
|
|
8226
8962
|
if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
|
|
8227
|
-
|
|
8963
|
+
const _sid = item.data && item.data.id;
|
|
8964
|
+
// ONE CALL PER CUE, NOT TWO. startTime/duration were stripped from visual,
|
|
8965
|
+
// so placing a cue meant create + set_placement every time — a pilot made
|
|
8966
|
+
// 160 calls where 80 would do. Applied here when given, using the same
|
|
8967
|
+
// setSoundPlacement the separate action calls.
|
|
8968
|
+
${placement ? `if (_sid && app.setSoundPlacement) { app.setSoundPlacement(_sid, ${S(placement)}); }` : ''}
|
|
8969
|
+
return { success: true, itemId: _sid, itemType: 'sound'${placement ? `, placement: ${S(placement)}` : ''} };
|
|
8228
8970
|
})();`.trim();
|
|
8971
|
+
}
|
|
8229
8972
|
case 'timbre_from_path':
|
|
8230
8973
|
return `
|
|
8231
8974
|
// Sound: read a drawn path's shape as harmonic content
|