@pinepaper.studio/mcp-server 1.6.7 → 1.6.9
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.de.md +9 -5
- package/README.es.md +9 -5
- package/README.fr.md +9 -5
- package/README.hi.md +8 -5
- package/README.ja.md +8 -5
- package/README.ko.md +8 -4
- package/README.md +256 -17
- package/README.pt-BR.md +9 -5
- package/README.zh-CN.md +6 -4
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/puppeteer-controller.d.ts +86 -2
- package/dist/browser/puppeteer-controller.d.ts.map +1 -1
- package/dist/browser/puppeteer-controller.js +143 -60
- package/dist/browser/puppeteer-controller.js.map +1 -1
- package/dist/character/data/concepts.json +1 -0
- package/dist/character/data/depictions.json +1 -0
- package/dist/character/data/styles.json +1 -0
- package/dist/character/duration-scale.d.ts +13 -0
- package/dist/character/duration-scale.d.ts.map +1 -0
- package/dist/character/duration-scale.js +13 -0
- package/dist/character/duration-scale.js.map +1 -0
- package/dist/character/path-bbox.d.ts +82 -0
- package/dist/character/path-bbox.d.ts.map +1 -0
- package/dist/character/path-bbox.js +496 -0
- package/dist/character/path-bbox.js.map +1 -0
- package/dist/character/perform.d.ts +149 -0
- package/dist/character/perform.d.ts.map +1 -0
- package/dist/character/perform.js +626 -0
- package/dist/character/perform.js.map +1 -0
- package/dist/character/resolve.d.ts +51 -0
- package/dist/character/resolve.d.ts.map +1 -0
- package/dist/character/resolve.js +239 -0
- package/dist/character/resolve.js.map +1 -0
- package/dist/cli.js +12 -0
- package/dist/cli.js.map +1 -1
- package/dist/design/design-systems.d.ts +224 -0
- package/dist/design/design-systems.d.ts.map +1 -0
- package/dist/design/design-systems.js +434 -0
- package/dist/design/design-systems.js.map +1 -0
- package/dist/execution/canvas-state.d.ts +7 -0
- package/dist/execution/canvas-state.d.ts.map +1 -1
- package/dist/execution/canvas-state.js +20 -8
- package/dist/execution/canvas-state.js.map +1 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/ontology/design-graph.d.ts +9 -0
- package/dist/ontology/design-graph.d.ts.map +1 -1
- package/dist/ontology/design-graph.js +98 -3
- package/dist/ontology/design-graph.js.map +1 -1
- package/dist/ontology/types.d.ts +42 -0
- package/dist/ontology/types.d.ts.map +1 -1
- package/dist/ontology/vocabulary.d.ts.map +1 -1
- package/dist/ontology/vocabulary.js +20 -0
- package/dist/ontology/vocabulary.js.map +1 -1
- package/dist/tools/compact-descriptions.d.ts.map +1 -1
- package/dist/tools/compact-descriptions.js +25 -0
- package/dist/tools/compact-descriptions.js.map +1 -1
- package/dist/tools/definitions.d.ts +1 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +404 -25
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/engine-methods.d.ts +18 -0
- package/dist/tools/engine-methods.d.ts.map +1 -0
- package/dist/tools/engine-methods.js +241 -0
- package/dist/tools/engine-methods.js.map +1 -0
- package/dist/tools/handlers/character.d.ts +121 -0
- package/dist/tools/handlers/character.d.ts.map +1 -0
- package/dist/tools/handlers/character.js +315 -0
- package/dist/tools/handlers/character.js.map +1 -0
- package/dist/tools/handlers.d.ts +2 -0
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/tools/handlers.js +522 -18
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/minimal-descriptions.d.ts.map +1 -1
- package/dist/tools/minimal-descriptions.js +15 -3
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/toolkits.d.ts.map +1 -1
- package/dist/tools/toolkits.js +8 -0
- package/dist/tools/toolkits.js.map +1 -1
- package/dist/types/code-generator.d.ts +106 -1
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +1273 -18
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +6805 -124
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +627 -17
- package/dist/types/schemas.js.map +1 -1
- package/dist/vendor/design/art-deco-generator.d.ts +52 -0
- package/dist/vendor/design/art-deco-generator.d.ts.map +1 -0
- package/dist/vendor/design/art-deco-generator.js +144 -0
- package/dist/vendor/design/art-deco-generator.js.map +1 -0
- package/dist/vendor/design/art-nouveau-generator.d.ts +46 -0
- package/dist/vendor/design/art-nouveau-generator.d.ts.map +1 -0
- package/dist/vendor/design/art-nouveau-generator.js +134 -0
- package/dist/vendor/design/art-nouveau-generator.js.map +1 -0
- package/dist/vendor/design/bauhaus-generator.d.ts +53 -0
- package/dist/vendor/design/bauhaus-generator.d.ts.map +1 -0
- package/dist/vendor/design/bauhaus-generator.js +126 -0
- package/dist/vendor/design/bauhaus-generator.js.map +1 -0
- package/dist/vendor/design/brutalist-generator.d.ts +53 -0
- package/dist/vendor/design/brutalist-generator.d.ts.map +1 -0
- package/dist/vendor/design/brutalist-generator.js +148 -0
- package/dist/vendor/design/brutalist-generator.js.map +1 -0
- package/dist/vendor/design/claymorphism-generator.d.ts +46 -0
- package/dist/vendor/design/claymorphism-generator.d.ts.map +1 -0
- package/dist/vendor/design/claymorphism-generator.js +135 -0
- package/dist/vendor/design/claymorphism-generator.js.map +1 -0
- package/dist/vendor/design/corporate-memphis-generator.d.ts +47 -0
- package/dist/vendor/design/corporate-memphis-generator.d.ts.map +1 -0
- package/dist/vendor/design/corporate-memphis-generator.js +144 -0
- package/dist/vendor/design/corporate-memphis-generator.js.map +1 -0
- package/dist/vendor/design/cyberpunk-generator.d.ts +52 -0
- package/dist/vendor/design/cyberpunk-generator.d.ts.map +1 -0
- package/dist/vendor/design/cyberpunk-generator.js +162 -0
- package/dist/vendor/design/cyberpunk-generator.js.map +1 -0
- package/dist/vendor/design/de-stijl-generator.d.ts +50 -0
- package/dist/vendor/design/de-stijl-generator.d.ts.map +1 -0
- package/dist/vendor/design/de-stijl-generator.js +154 -0
- package/dist/vendor/design/de-stijl-generator.js.map +1 -0
- package/dist/vendor/design/design-style.d.ts +3 -0
- package/dist/vendor/design/design-style.d.ts.map +1 -0
- package/dist/vendor/design/design-style.js +12 -0
- package/dist/vendor/design/design-style.js.map +1 -0
- package/dist/vendor/design/design-systems-dtcg.d.ts +59 -0
- package/dist/vendor/design/design-systems-dtcg.d.ts.map +1 -0
- package/dist/vendor/design/design-systems-dtcg.js +571 -0
- package/dist/vendor/design/design-systems-dtcg.js.map +1 -0
- package/dist/vendor/design/frutiger-aero-generator.d.ts +46 -0
- package/dist/vendor/design/frutiger-aero-generator.d.ts.map +1 -0
- package/dist/vendor/design/frutiger-aero-generator.js +133 -0
- package/dist/vendor/design/frutiger-aero-generator.js.map +1 -0
- package/dist/vendor/design/memphis-generator.d.ts +56 -0
- package/dist/vendor/design/memphis-generator.d.ts.map +1 -0
- package/dist/vendor/design/memphis-generator.js +158 -0
- package/dist/vendor/design/memphis-generator.js.map +1 -0
- package/dist/vendor/design/mid-century-upa-generator.d.ts +46 -0
- package/dist/vendor/design/mid-century-upa-generator.d.ts.map +1 -0
- package/dist/vendor/design/mid-century-upa-generator.js +137 -0
- package/dist/vendor/design/mid-century-upa-generator.js.map +1 -0
- package/dist/vendor/design/neumorphic-generator.d.ts +53 -0
- package/dist/vendor/design/neumorphic-generator.d.ts.map +1 -0
- package/dist/vendor/design/neumorphic-generator.js +182 -0
- package/dist/vendor/design/neumorphic-generator.js.map +1 -0
- package/dist/vendor/design/op-art-generator.d.ts +45 -0
- package/dist/vendor/design/op-art-generator.d.ts.map +1 -0
- package/dist/vendor/design/op-art-generator.js +116 -0
- package/dist/vendor/design/op-art-generator.js.map +1 -0
- package/dist/vendor/design/pixel-art-generator.d.ts +45 -0
- package/dist/vendor/design/pixel-art-generator.d.ts.map +1 -0
- package/dist/vendor/design/pixel-art-generator.js +143 -0
- package/dist/vendor/design/pixel-art-generator.js.map +1 -0
- package/dist/vendor/design/pop-art-generator.d.ts +53 -0
- package/dist/vendor/design/pop-art-generator.d.ts.map +1 -0
- package/dist/vendor/design/pop-art-generator.js +164 -0
- package/dist/vendor/design/pop-art-generator.js.map +1 -0
- package/dist/vendor/design/risograph-generator.d.ts +53 -0
- package/dist/vendor/design/risograph-generator.d.ts.map +1 -0
- package/dist/vendor/design/risograph-generator.js +170 -0
- package/dist/vendor/design/risograph-generator.js.map +1 -0
- package/dist/vendor/design/swiss-style-generator.d.ts +55 -0
- package/dist/vendor/design/swiss-style-generator.d.ts.map +1 -0
- package/dist/vendor/design/swiss-style-generator.js +147 -0
- package/dist/vendor/design/swiss-style-generator.js.map +1 -0
- package/dist/vendor/design/vaporwave-generator.d.ts +51 -0
- package/dist/vendor/design/vaporwave-generator.d.ts.map +1 -0
- package/dist/vendor/design/vaporwave-generator.js +139 -0
- package/dist/vendor/design/vaporwave-generator.js.map +1 -0
- package/manifest.json +33 -1
- package/package.json +13 -5
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Converts MCP tool inputs to executable PinePaper JavaScript code.
|
|
5
5
|
* This code is designed to run in the browser context where window.PinePaper is available.
|
|
6
6
|
*/
|
|
7
|
+
import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
|
|
7
8
|
import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
|
|
8
9
|
import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
9
10
|
// Diagram types
|
|
@@ -960,6 +961,39 @@ const PLATFORM_TO_CANVAS_PRESET = {
|
|
|
960
961
|
export function canvasPresetFor(platform) {
|
|
961
962
|
return PLATFORM_TO_CANVAS_PRESET[platform] || platform;
|
|
962
963
|
}
|
|
964
|
+
/**
|
|
965
|
+
* Largest export the emitted code will hand back as an inline base64 data URL.
|
|
966
|
+
*
|
|
967
|
+
* Only reached on a studio with no `app.exportEngine.exportToStore` — with the
|
|
968
|
+
* store, mp4/webm never base64 in the page at all. Set above what that legacy
|
|
969
|
+
* path could already deliver (60 s at 8 Mbps is roughly 60 MB, ~80 MB once
|
|
970
|
+
* base64'd) so raising the duration cap cannot make a previously working
|
|
971
|
+
* export start refusing; past it the code names the ceiling instead of dying
|
|
972
|
+
* inside the encode.
|
|
973
|
+
*/
|
|
974
|
+
export const INLINE_MAX_BYTES = 96 * 1024 * 1024;
|
|
975
|
+
/**
|
|
976
|
+
* Wait for the export subsystem before reading it.
|
|
977
|
+
*
|
|
978
|
+
* `app.exportEngine` is a LAZY getter over a code-split chunk: it returns
|
|
979
|
+
* undefined until `ensureHeavyModules()` has run, which the engine idle-prefetches
|
|
980
|
+
* roughly 1.2s after boot. An agent connecting and exporting immediately —
|
|
981
|
+
* which is the whole shape of an automated session — beats that prefetch, so
|
|
982
|
+
* every `app.exportEngine.*` read below found undefined and the generated code
|
|
983
|
+
* reported the studio as too old to export. The build was fine; the chunk had
|
|
984
|
+
* not landed yet. Awaiting it is the difference between a check and a race.
|
|
985
|
+
*
|
|
986
|
+
* The guards that follow this are left exactly as they are: after the await,
|
|
987
|
+
* an absent exportEngine really does mean a studio that cannot export.
|
|
988
|
+
*
|
|
989
|
+
* Emitted only into the two doors an agent can reach cold. `readExport` and
|
|
990
|
+
* `releaseExport` are only reachable after an export has already succeeded, by
|
|
991
|
+
* which point the chunk is resident by construction.
|
|
992
|
+
*/
|
|
993
|
+
const ENSURE_EXPORT_ENGINE = `
|
|
994
|
+
if (!app.exportEngine && typeof app.ensureHeavyModules === 'function') {
|
|
995
|
+
try { await app.ensureHeavyModules(); } catch (_) { /* the guards below still speak */ }
|
|
996
|
+
}`;
|
|
963
997
|
export class PinePaperCodeGenerator {
|
|
964
998
|
/**
|
|
965
999
|
* Generate code for creating an item
|
|
@@ -997,6 +1031,28 @@ export class PinePaperCodeGenerator {
|
|
|
997
1031
|
*/
|
|
998
1032
|
generateAddRelation(input) {
|
|
999
1033
|
const validated = AddRelationInputSchema.parse(input);
|
|
1034
|
+
// A PRESET decides its own relationType and params, so this is a different
|
|
1035
|
+
// call, not a defaulted one. applyRelationPreset refuses with
|
|
1036
|
+
// `{ok:false, errors:[{field, reason}]}` — a third refusal shape on this
|
|
1037
|
+
// surface, after {ok, reason} and {error} — so the errors are joined rather
|
|
1038
|
+
// than read as a single string, which would render "[object Object]".
|
|
1039
|
+
if (validated.presetId) {
|
|
1040
|
+
return `
|
|
1041
|
+
// Adopt relation preset "${validated.presetId}" on ${validated.sourceId}
|
|
1042
|
+
(async function() {
|
|
1043
|
+
if (typeof app.applyRelationPreset !== 'function') { return { success: false, error: 'app.applyRelationPreset unavailable — update FxTool' }; }
|
|
1044
|
+
const r = await app.applyRelationPreset(${JSON.stringify(validated.sourceId)}, ${JSON.stringify(validated.presetId)}, ${JSON.stringify(validated.presetValues ?? {})});
|
|
1045
|
+
if (!r || r.ok === false) {
|
|
1046
|
+
const why = (r && Array.isArray(r.errors))
|
|
1047
|
+
? r.errors.map(function(e) { return e.field + ': ' + e.reason; }).join('; ')
|
|
1048
|
+
: 'the preset did not apply';
|
|
1049
|
+
return { success: false, error: why };
|
|
1050
|
+
}
|
|
1051
|
+
// The preset's own type and resolved values come back, so the caller learns
|
|
1052
|
+
// what it actually adopted rather than only that something worked.
|
|
1053
|
+
return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
|
|
1054
|
+
})();`.trim();
|
|
1055
|
+
}
|
|
1000
1056
|
return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
|
|
1001
1057
|
}
|
|
1002
1058
|
/**
|
|
@@ -1234,8 +1290,29 @@ throw new Error('Either svgString or url must be provided');
|
|
|
1234
1290
|
};
|
|
1235
1291
|
}
|
|
1236
1292
|
const ctx = app.exportCanvasOntology(${optsJson});
|
|
1293
|
+
// SAY WHEN THE ANSWER IS PARTIAL. The engine returns itemCount =
|
|
1294
|
+
// allItems.length (the TRUE total) beside items = allItems.slice(0,
|
|
1295
|
+
// maxItems), and childCount beside a children array capped at maxChildren.
|
|
1296
|
+
// So a caller receives itemCount 800 with 500 items and nothing marking the
|
|
1297
|
+
// gap — an answer indistinguishable from a complete one unless the reader
|
|
1298
|
+
// thinks to compare two fields. That is the silent-refusal shape this repo
|
|
1299
|
+
// has fixed elsewhere; the marker below is the same idiom generateHatching
|
|
1300
|
+
// uses for its 40-path cap.
|
|
1301
|
+
var _shown = Array.isArray(ctx.items) ? ctx.items.length : 0;
|
|
1302
|
+
var _total = typeof ctx.itemCount === 'number' ? ctx.itemCount : _shown;
|
|
1303
|
+
var _clipped = (Array.isArray(ctx.items) ? ctx.items : []).filter(function (i) {
|
|
1304
|
+
return i && typeof i.childCount === 'number' && Array.isArray(i.children)
|
|
1305
|
+
&& i.children.length < i.childCount;
|
|
1306
|
+
});
|
|
1237
1307
|
return {
|
|
1238
1308
|
success: true,
|
|
1309
|
+
...(_total > _shown ? { truncated:
|
|
1310
|
+
'showing ' + _shown + ' of ' + _total + ' items. Raise maxItems (up to 500) '
|
|
1311
|
+
+ 'to see more; past 500 the scene is truncated rather than paginated, so '
|
|
1312
|
+
+ 'narrow with pinepaper_get_items or read a group at a time.' } : {}),
|
|
1313
|
+
...(_clipped.length ? { childrenTruncated: _clipped.map(function (i) {
|
|
1314
|
+
return i.id + ': ' + i.children.length + ' of ' + i.childCount + ' children';
|
|
1315
|
+
}) } : {}),
|
|
1239
1316
|
canvasWidth: ctx.canvasWidth,
|
|
1240
1317
|
canvasHeight: ctx.canvasHeight,
|
|
1241
1318
|
canvasPreset: ctx.canvasPreset,
|
|
@@ -2414,9 +2491,67 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2414
2491
|
/**
|
|
2415
2492
|
* Generate code for smart export
|
|
2416
2493
|
*/
|
|
2494
|
+
/**
|
|
2495
|
+
* Read one slice of a held export, base64-encoded.
|
|
2496
|
+
*
|
|
2497
|
+
* The handler calls this in a loop and appends each chunk to the file it was
|
|
2498
|
+
* going to write anyway, so no more than one chunk is ever in memory on
|
|
2499
|
+
* either side of the bridge. Every refusal the engine can give is passed
|
|
2500
|
+
* through untouched — a bad range names the real size, and an id that was
|
|
2501
|
+
* EVICTED to make room says so rather than reporting not-found, because
|
|
2502
|
+
* those are different bugs from the caller's side.
|
|
2503
|
+
*/
|
|
2504
|
+
generateReadExportChunk(exportId, offset, length) {
|
|
2505
|
+
const range = { offset };
|
|
2506
|
+
if (typeof length === 'number')
|
|
2507
|
+
range.length = length;
|
|
2508
|
+
return `
|
|
2509
|
+
// Read export chunk at ${offset}
|
|
2510
|
+
(async function() {
|
|
2511
|
+
if (!app.exportEngine || typeof app.exportEngine.readExport !== 'function') {
|
|
2512
|
+
return { ok: false, reason: 'app.exportEngine.readExport unavailable — update FxTool' };
|
|
2513
|
+
}
|
|
2514
|
+
return await app.exportEngine.readExport(${JSON.stringify(exportId)}, ${JSON.stringify(range)});
|
|
2515
|
+
})();`.trim();
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* What the studio is still holding.
|
|
2519
|
+
*
|
|
2520
|
+
* `id` IS the OPFS filename and OPFS is origin-persistent, so this survives a
|
|
2521
|
+
* page reload: an export whose id was lost — to a dropped tool result, a
|
|
2522
|
+
* crashed session, a reload — is recoverable from here rather than only by
|
|
2523
|
+
* rendering it again. Newest first, which is the order a caller looking for
|
|
2524
|
+
* "the one I just made" wants.
|
|
2525
|
+
*/
|
|
2526
|
+
generateListExports() {
|
|
2527
|
+
return `
|
|
2528
|
+
// What the export store is holding
|
|
2529
|
+
(async function() {${ENSURE_EXPORT_ENGINE}
|
|
2530
|
+
if (!app.exportEngine || typeof app.exportEngine.listExports !== 'function') {
|
|
2531
|
+
return { ok: false, reason: 'app.exportEngine.listExports unavailable — update FxTool' };
|
|
2532
|
+
}
|
|
2533
|
+
return { ok: true, exports: await app.exportEngine.listExports() };
|
|
2534
|
+
})();`.trim();
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Drop a held export once its bytes are safely on disk here.
|
|
2538
|
+
*
|
|
2539
|
+
* Called only after the whole file has been written. A release before that
|
|
2540
|
+
* would be unrecoverable: the store is the only copy.
|
|
2541
|
+
*/
|
|
2542
|
+
generateReleaseExport(exportId) {
|
|
2543
|
+
return `
|
|
2544
|
+
// Release held export
|
|
2545
|
+
(async function() {
|
|
2546
|
+
if (!app.exportEngine || typeof app.exportEngine.releaseExport !== 'function') {
|
|
2547
|
+
return { ok: false, reason: 'app.exportEngine.releaseExport unavailable — update FxTool' };
|
|
2548
|
+
}
|
|
2549
|
+
return await app.exportEngine.releaseExport(${JSON.stringify(exportId)});
|
|
2550
|
+
})();`.trim();
|
|
2551
|
+
}
|
|
2417
2552
|
generateAgentExport(input) {
|
|
2418
2553
|
const validated = AgentExportInputSchema.parse(input);
|
|
2419
|
-
const { platform, format, quality, framing, duration, estimateOnly } = validated;
|
|
2554
|
+
const { platform, format, quality, framing, duration, estimateOnly, scale } = validated;
|
|
2420
2555
|
const qualityLevel = quality || 'standard';
|
|
2421
2556
|
const videoDuration = duration ?? 5;
|
|
2422
2557
|
// Quality settings (bitrate in bps for VideoEncoder)
|
|
@@ -2439,17 +2574,103 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2439
2574
|
'print-letter': { width: 2550, height: 3300, staticFormat: 'pdf', animatedFormat: 'pdf' },
|
|
2440
2575
|
};
|
|
2441
2576
|
const preset = platformPresets[platform] || platformPresets['web'];
|
|
2577
|
+
// SCALE is the only honest size control this surface has.
|
|
2578
|
+
//
|
|
2579
|
+
// A caller who wants a smaller file reaches for a bitrate, and there is
|
|
2580
|
+
// nowhere to put one: VideoExporter computes its own target from
|
|
2581
|
+
// resolution, quality and fps via _calculateBitrate and never reads a
|
|
2582
|
+
// caller-supplied value — passing one would be a knob that silently does
|
|
2583
|
+
// nothing. Resolution is an INPUT to that calculation, so halving the
|
|
2584
|
+
// frame genuinely halves the encode target, and it is also what makes a
|
|
2585
|
+
// preview render fast. One number, both jobs, and it is real.
|
|
2586
|
+
//
|
|
2587
|
+
// Rounded to EVEN: H.264 chroma subsampling is 4:2:0, and an odd dimension
|
|
2588
|
+
// is rejected by the encoder rather than rounded for you.
|
|
2589
|
+
const even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
2590
|
+
const scaled = scale !== undefined && scale !== 1
|
|
2591
|
+
? { width: even(preset.width * scale), height: even(preset.height * scale) }
|
|
2592
|
+
: { width: preset.width, height: preset.height };
|
|
2442
2593
|
// Resolve "auto" format to the platform's recommended format
|
|
2443
2594
|
const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
|
|
2595
|
+
// AUDIO-ONLY EXPORT, off the visual path entirely.
|
|
2596
|
+
//
|
|
2597
|
+
// It takes the OFFLINE renderer, not exportEngine.exportAudio. The latter
|
|
2598
|
+
// is async, needs the videoExporter's Web Audio path, hands back a Blob
|
|
2599
|
+
// that cannot cross page.evaluate, and downloads unless told not to.
|
|
2600
|
+
// renderSoundtrackWav is synchronous, returns bytes, and needs no audio
|
|
2601
|
+
// context — the engine commit that added it is titled "the soundtrack
|
|
2602
|
+
// without Web Audio", and this is the context it was written for.
|
|
2603
|
+
//
|
|
2604
|
+
// No platform preset resolves to wav, so reaching here means it was asked
|
|
2605
|
+
// for by name. Dimensions, framing, fps and bitrate are all absent rather
|
|
2606
|
+
// than passed and ignored.
|
|
2607
|
+
// @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio ensureHeavyModules
|
|
2608
|
+
if (exportFormat === 'wav') {
|
|
2609
|
+
const sr = validated.sampleRate ?? 48000;
|
|
2610
|
+
const bd = validated.bitDepth ?? 16;
|
|
2611
|
+
return `
|
|
2612
|
+
// Export: the soundtrack on its own, rendered offline
|
|
2613
|
+
(async function() {${ENSURE_EXPORT_ENGINE}
|
|
2614
|
+
if (typeof app.renderSoundtrackWav !== 'function') {
|
|
2615
|
+
return { success: false, format: 'wav', error: 'app.renderSoundtrackWav unavailable — update FxTool. This build cannot export audio on its own; mp4/webm still mux the soundtrack into the video.' };
|
|
2616
|
+
}
|
|
2617
|
+
// EXACT, not estimated. Uncompressed PCM is header + rate x bytes x channels
|
|
2618
|
+
// x seconds, so a model is not needed and would only be less true. The mix
|
|
2619
|
+
// is mono — the engine encodes a single channel.
|
|
2620
|
+
var exact = 44 + Math.round(${sr} * (${bd} / 8) * 1 * ${videoDuration});
|
|
2621
|
+
if (${estimateOnly ? 'true' : 'false'}) {
|
|
2622
|
+
return { success: true, estimateOnly: true, format: 'wav', bytes: exact,
|
|
2623
|
+
expected: exact, confidence: 'exact',
|
|
2624
|
+
note: 'uncompressed PCM: ' + ${videoDuration} + 's at ' + ${sr} + ' Hz, ' + ${bd} + '-bit mono' };
|
|
2625
|
+
}
|
|
2626
|
+
var r = app.renderSoundtrackWav({ duration: ${videoDuration}, sampleRate: ${sr}, bitDepth: ${bd} });
|
|
2627
|
+
if (!r || !r.wav) {
|
|
2628
|
+
// WHY IT IS EMPTY, NOT JUST THAT IT IS. The offline renderer bakes
|
|
2629
|
+
// SYNTHESIZED sounds only, while the engine's sceneHasAudio() counts
|
|
2630
|
+
// uploaded audio files too. A scene carrying an uploaded track would
|
|
2631
|
+
// otherwise be told it has no audio, which is false and sends the caller
|
|
2632
|
+
// to add a sound they already have.
|
|
2633
|
+
var anyAudio = null;
|
|
2634
|
+
try {
|
|
2635
|
+
if (app.exportEngine && typeof app.exportEngine.sceneHasAudio === 'function') {
|
|
2636
|
+
anyAudio = app.exportEngine.sceneHasAudio();
|
|
2637
|
+
}
|
|
2638
|
+
} catch (_) { /* predicate absent — fall back to the generic answer */ }
|
|
2639
|
+
return {
|
|
2640
|
+
success: false, format: 'wav',
|
|
2641
|
+
error: anyAudio === true
|
|
2642
|
+
? 'this scene has audio, but it is uploaded file(s) rather than synthesized sounds, and the offline renderer bakes only what it can synthesize. Export mp4 or webm to get that audio muxed with the picture.'
|
|
2643
|
+
: 'no synthesized sounds are placed on this scene, so there is no soundtrack to render. Create one with pinepaper_sound action "create", or place an existing one with "set_placement".',
|
|
2644
|
+
sceneHasAudio: anyAudio,
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
var bytes = r.wav;
|
|
2648
|
+
// Chunked: String.fromCharCode.apply over a multi-megabyte array blows the
|
|
2649
|
+
// stack, and a ten-minute soundtrack is tens of megabytes.
|
|
2650
|
+
var bin = '';
|
|
2651
|
+
for (var i = 0; i < bytes.length; i += 8192) {
|
|
2652
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
2653
|
+
}
|
|
2654
|
+
return {
|
|
2655
|
+
success: true, format: 'wav', platform: '${platform}',
|
|
2656
|
+
data: 'data:audio/wav;base64,' + btoa(bin),
|
|
2657
|
+
size: bytes.length,
|
|
2658
|
+
duration: r.duration, sampleRate: r.sampleRate, sounds: r.sounds, placed: r.placed,
|
|
2659
|
+
// A mix that dropped sounds is a valid file quietly missing tracks.
|
|
2660
|
+
...(r.dropped > 0 ? { dropped: r.dropped, incomplete:
|
|
2661
|
+
'mixed ' + r.placed + ' sound(s); ' + r.dropped + ' could not be rendered and are absent from this file' } : {}),
|
|
2662
|
+
};
|
|
2663
|
+
})();`.trim();
|
|
2664
|
+
}
|
|
2444
2665
|
return `
|
|
2445
2666
|
// Smart export for ${platform}
|
|
2446
|
-
(async function() {
|
|
2667
|
+
(async function() {${ENSURE_EXPORT_ENGINE}
|
|
2447
2668
|
const platform = '${platform}';
|
|
2448
2669
|
const format = '${exportFormat}';
|
|
2449
2670
|
const quality = '${qualityLevel}';
|
|
2450
2671
|
const framing = '${framing}';
|
|
2451
2672
|
const settings = ${JSON.stringify(qualitySettings)};
|
|
2452
|
-
const dimensions = ${JSON.stringify(
|
|
2673
|
+
const dimensions = ${JSON.stringify(scaled)};
|
|
2453
2674
|
|
|
2454
2675
|
// Preflight: same resolved settings as the real export, but render nothing.
|
|
2455
2676
|
// Branching HERE rather than in a separate tool is deliberate — the estimate
|
|
@@ -2480,7 +2701,12 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2480
2701
|
|
|
2481
2702
|
// Resolve camera-view framing dims when requested. Reads the first
|
|
2482
2703
|
// camera_animates keyframe's zoom and divides the canvas dims by it
|
|
2483
|
-
// (mirrors
|
|
2704
|
+
// (mirrors the camera-view framing block in FxTool's
|
|
2705
|
+
// ExportEngine.showVideoExportOptions). Cited by METHOD, not by line: the
|
|
2706
|
+
// previous citation named a line range that had rotted to point at
|
|
2707
|
+
// DPI/canvas-size code in a file nobody here edits. A cross-file line range
|
|
2708
|
+
// cannot be kept accurate — it breaks on any edit above it — so this repo
|
|
2709
|
+
// does not use them. Camera animation still
|
|
2484
2710
|
// drives motion during export — framing only fixes the output frame.
|
|
2485
2711
|
let cameraDims = null;
|
|
2486
2712
|
if (framing === 'camera') {
|
|
@@ -2559,38 +2785,119 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2559
2785
|
// ExportEngine._quickExportVideo overrides quality with undefined for
|
|
2560
2786
|
// mp4/webm, _calculateBitrate yields NaN, and VideoEncoder.configure
|
|
2561
2787
|
// rejects with 'malformed bitrate value'.
|
|
2562
|
-
|
|
2788
|
+
// width/height ride along ONLY when a scale was asked for. The buffered
|
|
2789
|
+
// fallback paths otherwise export at canvas size, and quietly changing
|
|
2790
|
+
// that for every existing caller is not what a new optional knob does.
|
|
2791
|
+
const baseVideoSettings = { format, fps: settings.fps, quality: settings.compression, duration: ${videoDuration}${scale !== undefined && scale !== 1 ? ', width: dimensions.width, height: dimensions.height' : ''} };
|
|
2563
2792
|
const blobToDataUrl = (b) => new Promise(resolve => {
|
|
2564
2793
|
const r = new FileReader();
|
|
2565
2794
|
r.onloadend = () => resolve(r.result);
|
|
2566
2795
|
r.readAsDataURL(b);
|
|
2567
2796
|
});
|
|
2797
|
+
|
|
2798
|
+
// THE EXPORT STORE. A long export cannot come back as one base64
|
|
2799
|
+
// string: base64 of a gigabyte is larger than the gigabyte, and it
|
|
2800
|
+
// crosses the browser→server bridge as a single page.evaluate return
|
|
2801
|
+
// value. exportToStore() streams the encode straight to the studio's
|
|
2802
|
+
// origin-private store and hands back an id, so the bytes stay on disk
|
|
2803
|
+
// and the handler pages them into the file it was going to write
|
|
2804
|
+
// anyway. mp4/webm ALWAYS land in a file on the server side, so there
|
|
2805
|
+
// is no size at which building a data URL for them is worth doing —
|
|
2806
|
+
// the id goes back at every size and nothing is base64'd in the page.
|
|
2807
|
+
// gif is refused by name by the store (it has its own encoder and
|
|
2808
|
+
// never streams), so gif keeps the buffered path below.
|
|
2809
|
+
const store = (app.exportEngine
|
|
2810
|
+
&& typeof app.exportEngine.exportToStore === 'function'
|
|
2811
|
+
&& typeof app.exportEngine.readExport === 'function')
|
|
2812
|
+
? app.exportEngine : null;
|
|
2813
|
+
|
|
2814
|
+
if (store && format !== 'gif') {
|
|
2815
|
+
const stored = await store.exportToStore({
|
|
2816
|
+
format,
|
|
2817
|
+
duration: baseVideoSettings.duration,
|
|
2818
|
+
fps: settings.fps,
|
|
2819
|
+
// A NUMBER, not the quality tier's name. _calculateBitrate
|
|
2820
|
+
// multiplies by it, and a non-finite value used to survive its
|
|
2821
|
+
// Math.max(100000, NaN) "floor" all the way to
|
|
2822
|
+
// VideoEncoder.configure.
|
|
2823
|
+
quality: settings.compression,
|
|
2824
|
+
width: cameraDims ? cameraDims.width : dimensions.width,
|
|
2825
|
+
height: cameraDims ? cameraDims.height : dimensions.height,
|
|
2826
|
+
});
|
|
2827
|
+
if (!stored || stored.ok === false) {
|
|
2828
|
+
result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why' };
|
|
2829
|
+
} else {
|
|
2830
|
+
let chunkBytes = 4 * 1024 * 1024;
|
|
2831
|
+
try {
|
|
2832
|
+
const declared = app.exportEngine.constructor && app.exportEngine.constructor.EXPORT_CHUNK_BYTES;
|
|
2833
|
+
if (typeof declared === 'number' && declared > 0) chunkBytes = declared;
|
|
2834
|
+
} catch (_) { /* keep the default */ }
|
|
2835
|
+
result = {
|
|
2836
|
+
success: true, platform, framing,
|
|
2837
|
+
// Trust the container the store reports over the one asked for:
|
|
2838
|
+
// mp4 falls back to WebM without WebCodecs, and WebM bytes named
|
|
2839
|
+
// .mp4 fail in container-validating players.
|
|
2840
|
+
format: stored.format || format,
|
|
2841
|
+
retained: true,
|
|
2842
|
+
exportId: stored.exportId,
|
|
2843
|
+
mimeType: stored.mimeType || videoMimeType,
|
|
2844
|
+
size: stored.size,
|
|
2845
|
+
chunkBytes: chunkBytes,
|
|
2846
|
+
dimensions: cameraDims || dimensions,
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
break;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// NO EXPORT STORE ON THIS STUDIO, or gif. Both remaining failures are
|
|
2853
|
+
// named rather than left to blow up: a streamed export hands back a
|
|
2854
|
+
// marker instead of a Blob and FileReader throws on it, and an export
|
|
2855
|
+
// past the inline ceiling used to die somewhere in the base64 rather
|
|
2856
|
+
// than say so. The ceiling sits above what this path could already do
|
|
2857
|
+
// (60 s at 8 Mbps is roughly 60 MB) so nothing that works today starts
|
|
2858
|
+
// refusing.
|
|
2859
|
+
const INLINE_MAX_BYTES = ${INLINE_MAX_BYTES};
|
|
2860
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
2861
|
+
const deliver = async (blob) => {
|
|
2862
|
+
if (!blob || typeof blob.size !== 'number' || typeof blob.slice !== 'function') {
|
|
2863
|
+
return {
|
|
2864
|
+
success: false, platform, format,
|
|
2865
|
+
error: 'this export streamed to a file instead of returning bytes, and this studio has no app.exportEngine.exportToStore to page it back from — update FxTool for long-form export',
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
if (blob.size > INLINE_MAX_BYTES) {
|
|
2869
|
+
return {
|
|
2870
|
+
success: false, platform, format, size: blob.size,
|
|
2871
|
+
error: 'export is ' + mb(blob.size) + ' MB, over the ' + mb(INLINE_MAX_BYTES)
|
|
2872
|
+
+ ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
return { success: true, platform, format, framing, data: await blobToDataUrl(blob), mimeType: videoMimeType, size: blob.size };
|
|
2876
|
+
};
|
|
2877
|
+
|
|
2568
2878
|
// Camera framing requires going direct to videoExporter so width/height
|
|
2569
2879
|
// pass through — _quickExportVideo strips dim fields.
|
|
2570
2880
|
if (cameraDims && app.exportEngine && app.exportEngine.videoExporter) {
|
|
2571
2881
|
const blob = await app.exportEngine.videoExporter.export({ ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height });
|
|
2572
|
-
|
|
2573
|
-
result
|
|
2882
|
+
result = await deliver(blob);
|
|
2883
|
+
if (result.success) result.dimensions = cameraDims;
|
|
2574
2884
|
} else if ((format === 'mp4' || format === 'webm') && app.exportEngine && app.exportEngine.videoExporter) {
|
|
2575
2885
|
// Bypass _quickExportVideo for mp4/webm — it hardcodes
|
|
2576
2886
|
// quality:undefined for these formats and produces a NaN bitrate.
|
|
2577
2887
|
const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
|
|
2578
|
-
|
|
2579
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: blob.size };
|
|
2888
|
+
result = await deliver(blob);
|
|
2580
2889
|
} else if (app.exportEngine && app.exportEngine._quickExportVideo) {
|
|
2581
2890
|
// GIF path (working): _quickExportVideo forwards gifQuality to
|
|
2582
2891
|
// gif.js. Also serves as the fallback if videoExporter is absent.
|
|
2583
2892
|
const videoResult = await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);
|
|
2584
2893
|
if (videoResult && videoResult.blob) {
|
|
2585
|
-
|
|
2586
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: videoResult.blob.size };
|
|
2894
|
+
result = await deliver(videoResult.blob);
|
|
2587
2895
|
} else {
|
|
2588
2896
|
result = { success: false, error: format.toUpperCase() + ' export returned no data' };
|
|
2589
2897
|
}
|
|
2590
2898
|
} else if (app.exportEngine && app.exportEngine.videoExporter) {
|
|
2591
2899
|
const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
|
|
2592
|
-
|
|
2593
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: blob.size };
|
|
2900
|
+
result = await deliver(blob);
|
|
2594
2901
|
} else {
|
|
2595
2902
|
result = { success: false, error: format.toUpperCase() + ' export not available' };
|
|
2596
2903
|
}
|
|
@@ -2830,8 +3137,15 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2830
3137
|
return { success: false, error: 'Letter collage animation not available' };
|
|
2831
3138
|
}
|
|
2832
3139
|
|
|
2833
|
-
|
|
2834
|
-
|
|
3140
|
+
// POSITIONAL, and it was not: the engine signature is
|
|
3141
|
+
// animateLetterCollage(collageId, animationType, options), in PinePaper.js.
|
|
3142
|
+
// Passing one object put it in the animationType slot, so the type the caller
|
|
3143
|
+
// asked for was discarded on EVERY call and the collage animated as whatever
|
|
3144
|
+
// the engine falls back to. Silent: the call still returned, still reported
|
|
3145
|
+
// success, and the collage still moved a little, which is why it read as
|
|
3146
|
+
// "the flagship text effect doesn't survive export" rather than as a
|
|
3147
|
+
// dropped argument.
|
|
3148
|
+
const result = app.animateLetterCollage(collageId, animationType, {
|
|
2835
3149
|
staggerDelay,
|
|
2836
3150
|
animationSpeed
|
|
2837
3151
|
});
|
|
@@ -5227,6 +5541,79 @@ ${resolve}
|
|
|
5227
5541
|
const key = ${S(validated.key)};
|
|
5228
5542
|
const exclude = ${S(validated.exclude)};
|
|
5229
5543
|
|
|
5544
|
+
// WHICH ENGINE METHODS THIS STUDIO HAS.
|
|
5545
|
+
//
|
|
5546
|
+
// Every tool guards the methods it needs and refuses by name without them,
|
|
5547
|
+
// which is right per call and useless in advance: an agent otherwise learns a
|
|
5548
|
+
// studio is too old one tool at a time. getCapabilities cannot answer this —
|
|
5549
|
+
// it reports the engine's VOCABULARY (styles, palettes, relations), not which
|
|
5550
|
+
// of its methods exist.
|
|
5551
|
+
//
|
|
5552
|
+
// The list is DERIVED from the emitters' own guards by
|
|
5553
|
+
// scripts/sync-engine-methods.mjs, so it cannot drift from what the tools
|
|
5554
|
+
// actually check. Required and optional are kept apart because an older
|
|
5555
|
+
// studio that degrades is not an unusable one.
|
|
5556
|
+
if (action === 'studio') {
|
|
5557
|
+
const required = ${S(REQUIRED_ENGINE_METHODS)};
|
|
5558
|
+
const optional = ${S(OPTIONAL_ENGINE_METHODS)};
|
|
5559
|
+
const has = (path) => {
|
|
5560
|
+
// Dotted paths: thirteen methods live behind a sub-facade
|
|
5561
|
+
// (exportEngine.exportToStore, mapSystem.worldTour).
|
|
5562
|
+
let node = app;
|
|
5563
|
+
for (const part of path.split('.')) {
|
|
5564
|
+
if (!node) return false;
|
|
5565
|
+
node = node[part];
|
|
5566
|
+
}
|
|
5567
|
+
return typeof node === 'function';
|
|
5568
|
+
};
|
|
5569
|
+
const missingRequired = required.filter((m) => !has(m));
|
|
5570
|
+
const missingOptional = optional.filter((m) => !has(m));
|
|
5571
|
+
return {
|
|
5572
|
+
success: true,
|
|
5573
|
+
studio: {
|
|
5574
|
+
requiredTotal: required.length,
|
|
5575
|
+
requiredPresent: required.length - missingRequired.length,
|
|
5576
|
+
missingRequired: missingRequired,
|
|
5577
|
+
optionalTotal: optional.length,
|
|
5578
|
+
missingOptional: missingOptional,
|
|
5579
|
+
// A studio missing nothing required can serve every tool this server
|
|
5580
|
+
// publishes. Missing something optional only narrows an answer.
|
|
5581
|
+
complete: missingRequired.length === 0,
|
|
5582
|
+
},
|
|
5583
|
+
};
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5586
|
+
// The registries getCapabilities does NOT aggregate. Each is a capability an
|
|
5587
|
+
// agent otherwise has to guess at, which by this project's rule is the same
|
|
5588
|
+
// as its not existing. Read one at a time and name a missing facade rather
|
|
5589
|
+
// than answering with an empty list, which would read as "none exist".
|
|
5590
|
+
if (action === 'catalogue') {
|
|
5591
|
+
const READERS = {
|
|
5592
|
+
rig_presets: 'listRigPresets', shader_effects: 'listShaderEffects',
|
|
5593
|
+
stroke_decorations: 'listStrokeDecorations', precomps: 'listPrecomps',
|
|
5594
|
+
images: 'listImages', segment_edit_kinds: 'listSegmentEditKinds',
|
|
5595
|
+
shatter_orders: 'listShatterOrders', world_meshes: 'listWorldMeshes',
|
|
5596
|
+
relation_presets: 'loadRelationPresets',
|
|
5597
|
+
};
|
|
5598
|
+
const want = ${S(validated.catalogue)};
|
|
5599
|
+
const fn = READERS[want];
|
|
5600
|
+
if (!fn) { return { success: false, error: 'unknown catalogue ' + JSON.stringify(want) + ' — try ' + Object.keys(READERS).join(', ') }; }
|
|
5601
|
+
if (typeof app[fn] !== 'function') { return { success: false, error: 'app.' + fn + ' unavailable — update FxTool' }; }
|
|
5602
|
+
const entries = await app[fn]();
|
|
5603
|
+
// loadRelationPresets answers {presets, rejected, source} rather than a bare
|
|
5604
|
+
// array, and REJECTED is the half worth surfacing: a preset this build
|
|
5605
|
+
// cannot use is a named absence, where dropping it silently would read as
|
|
5606
|
+
// the catalogue simply being smaller.
|
|
5607
|
+
if (entries && !Array.isArray(entries) && Array.isArray(entries.presets)) {
|
|
5608
|
+
return {
|
|
5609
|
+
success: true, catalogue: want, reader: 'app.' + fn,
|
|
5610
|
+
count: entries.presets.length, entries: entries.presets,
|
|
5611
|
+
rejected: entries.rejected || [], source: entries.source || null,
|
|
5612
|
+
};
|
|
5613
|
+
}
|
|
5614
|
+
return { success: true, catalogue: want, reader: 'app.' + fn, count: Array.isArray(entries) ? entries.length : undefined, entries: entries };
|
|
5615
|
+
}
|
|
5616
|
+
|
|
5230
5617
|
if (typeof app.getCapabilities !== 'function') {
|
|
5231
5618
|
return { success: false, error: 'app.getCapabilities unavailable — update FxTool' };
|
|
5232
5619
|
}
|
|
@@ -5876,6 +6263,7 @@ ${resolve}
|
|
|
5876
6263
|
* (bringToFront / sendToBack / bringForward / sendBackward).
|
|
5877
6264
|
*/
|
|
5878
6265
|
generateArrange(input) {
|
|
6266
|
+
// @engine-methods bringToFront sendToBack bringForward sendBackward
|
|
5879
6267
|
const method = {
|
|
5880
6268
|
front: 'bringToFront',
|
|
5881
6269
|
back: 'sendToBack',
|
|
@@ -6114,9 +6502,41 @@ ${resolve}
|
|
|
6114
6502
|
}).compile(input.doc);
|
|
6115
6503
|
const opsJson = JSON.stringify(ops, null, 2);
|
|
6116
6504
|
const diagJson = JSON.stringify(diagnostics);
|
|
6505
|
+
const docJson = JSON.stringify(input.doc);
|
|
6506
|
+
// awaitImages: the studio decorates a raster (fit, keyframes, mask) on its
|
|
6507
|
+
// 'load' event and re-runs its finalize steps when the last one lands; a
|
|
6508
|
+
// tool call that screenshots next wants that to have happened.
|
|
6509
|
+
const engineOptsJson = JSON.stringify({ canvas: input.canvas, defaultGeometry: input.defaultGeometry, awaitImages: true });
|
|
6117
6510
|
return `
|
|
6118
6511
|
// Instantiate ontology → scene (${ops.filter((o) => o.op === 'create').length} items, ${ops.filter((o) => o.op === 'addRelation').length} relations)
|
|
6119
|
-
(function() {
|
|
6512
|
+
(async function() {
|
|
6513
|
+
// Engine door first: the studio's own OntologyCompiler reads every node facet
|
|
6514
|
+
// (geometry, paint, text, the keyframe track, masks) and builds through the
|
|
6515
|
+
// template loader's paths — the round trip the studio's pixel tests verify
|
|
6516
|
+
// (e2e/graph-instantiate-parity.spec.js). The server-side compile above is
|
|
6517
|
+
// kept for the diagnostics preview and for studios that predate the facade,
|
|
6518
|
+
// which get the box loop below and a note saying so.
|
|
6519
|
+
const doc = ${docJson};
|
|
6520
|
+
if (typeof app.instantiateOntology === 'function') {
|
|
6521
|
+
const r = await app.instantiateOntology(doc, ${engineOptsJson});
|
|
6522
|
+
const diags = r.diagnostics || [];
|
|
6523
|
+
return {
|
|
6524
|
+
success: !diags.some(function(d) { return d && d.level === 'error'; }) && !r.imagesFailed,
|
|
6525
|
+
engine: 'studio',
|
|
6526
|
+
itemIds: r.itemIds, itemCount: r.itemIds.length,
|
|
6527
|
+
// Every count the engine returns, not a chosen three. imagesFailed is the
|
|
6528
|
+
// one that matters most: a raster that never decoded leaves a scene that
|
|
6529
|
+
// looks built and is not, and dropping the count is how that becomes
|
|
6530
|
+
// invisible. relationsApplied and connectorsApplied are the only way to
|
|
6531
|
+
// tell "the doc declared no edges" from "the edges did not bind".
|
|
6532
|
+
keyframesApplied: r.keyframesApplied, masksApplied: r.masksApplied, deferred: r.deferred,
|
|
6533
|
+
relationsApplied: r.relationsApplied, connectorsApplied: r.connectorsApplied,
|
|
6534
|
+
interactionsApplied: r.interactionsApplied, effectsApplied: r.effectsApplied,
|
|
6535
|
+
groupsCreated: r.groupsCreated, nested: r.nested,
|
|
6536
|
+
imagesFailed: r.imagesFailed, backgroundApplied: r.backgroundApplied, duration: r.duration,
|
|
6537
|
+
diagnostics: diags, errors: [],
|
|
6538
|
+
};
|
|
6539
|
+
}
|
|
6120
6540
|
if (typeof app.create !== 'function' || typeof app.addRelation !== 'function') {
|
|
6121
6541
|
return { success: false, error: 'app.create / app.addRelation unavailable' };
|
|
6122
6542
|
}
|
|
@@ -6137,7 +6557,7 @@ ${resolve}
|
|
|
6137
6557
|
}
|
|
6138
6558
|
}
|
|
6139
6559
|
if (app.historyManager) app.historyManager.saveState();
|
|
6140
|
-
return { success: errors.length === 0, itemIds: itemIds, itemCount: itemIds.length, diagnostics: diagnostics, errors: errors };
|
|
6560
|
+
return { success: errors.length === 0, engine: 'server-box', note: 'this studio predates app.instantiateOntology: items were placed as boxes without paint, text, keyframes or masks', itemIds: itemIds, itemCount: itemIds.length, diagnostics: diagnostics, errors: errors };
|
|
6141
6561
|
})();`.trim();
|
|
6142
6562
|
}
|
|
6143
6563
|
/**
|
|
@@ -6360,6 +6780,48 @@ ${guard}
|
|
|
6360
6780
|
};
|
|
6361
6781
|
})();`.trim();
|
|
6362
6782
|
}
|
|
6783
|
+
case 'cursive':
|
|
6784
|
+
return `
|
|
6785
|
+
// Text: write it as STROKED handwriting — a path, not a glyph
|
|
6786
|
+
(function() {
|
|
6787
|
+
if (typeof app.createCursiveText !== 'function') { return { success: false, error: 'app.createCursiveText unavailable — update FxTool' }; }
|
|
6788
|
+
const item = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
|
|
6789
|
+
if (!item) { return { success: false, error: 'the cursive text produced no path' }; }
|
|
6790
|
+
return { success: true, itemId: item.data && item.data.id };
|
|
6791
|
+
})();`.trim();
|
|
6792
|
+
case 'wrap':
|
|
6793
|
+
return `
|
|
6794
|
+
// Text: break to a width — reversibly
|
|
6795
|
+
(function() {
|
|
6796
|
+
if (typeof app.wrapText !== 'function') { return { success: false, error: 'app.wrapText unavailable — update FxTool' }; }
|
|
6797
|
+
const r = app.wrapText(${S(input.itemId)}, ${S(input.maxWidth)});
|
|
6798
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'that item could not be wrapped' }; }
|
|
6799
|
+
return { success: true, ...r };
|
|
6800
|
+
})();`.trim();
|
|
6801
|
+
case 'unwrap':
|
|
6802
|
+
return `
|
|
6803
|
+
// Text: restore the single line wrap replaced
|
|
6804
|
+
(function() {
|
|
6805
|
+
if (typeof app.unwrapText !== 'function') { return { success: false, error: 'app.unwrapText unavailable — update FxTool' }; }
|
|
6806
|
+
// 'not wrapped' is the engine's own refusal and is more useful than
|
|
6807
|
+
// anything this layer could infer, so it is passed through.
|
|
6808
|
+
const r = app.unwrapText(${S(input.itemId)});
|
|
6809
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'that item is not wrapped' }; }
|
|
6810
|
+
return { success: true, ...r };
|
|
6811
|
+
})();`.trim();
|
|
6812
|
+
case 'to_collage':
|
|
6813
|
+
return `
|
|
6814
|
+
// Text: turn an EXISTING text item into a letter collage, in place
|
|
6815
|
+
(function() {
|
|
6816
|
+
if (typeof app.convertTextToCollage !== 'function') { return { success: false, error: 'app.convertTextToCollage unavailable — update FxTool' }; }
|
|
6817
|
+
const item = app.getItemById ? app.getItemById(${S(input.itemId)}) : null;
|
|
6818
|
+
if (!item) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
6819
|
+
// Returns null when there is no letterCollage subsystem — a bare null that
|
|
6820
|
+
// would otherwise read as "the conversion did nothing to your text".
|
|
6821
|
+
const r = app.convertTextToCollage(item, ${S(input.text ?? null)} || item.content, ${S(input.collageOptions ?? {})});
|
|
6822
|
+
if (!r) { return { success: false, error: 'the collage system is unavailable in this build, so the text was left as it was' }; }
|
|
6823
|
+
return { success: true, collage: r };
|
|
6824
|
+
})();`.trim();
|
|
6363
6825
|
}
|
|
6364
6826
|
}
|
|
6365
6827
|
/**
|
|
@@ -6406,7 +6868,14 @@ ${guard}
|
|
|
6406
6868
|
...(input.color !== undefined ? { color: input.color } : {}),
|
|
6407
6869
|
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
6408
6870
|
...(input.count !== undefined ? { count: input.count } : {}),
|
|
6871
|
+
...(input.slant !== undefined ? { slant: input.slant } : {}),
|
|
6872
|
+
...(input.overlap !== undefined ? { overlap: input.overlap } : {}),
|
|
6873
|
+
...(input.stagger !== undefined ? { stagger: input.stagger } : {}),
|
|
6874
|
+
...(input.gapLen !== undefined ? { gapLen: input.gapLen } : {}),
|
|
6875
|
+
...(input.gridSize !== undefined ? { gridSize: input.gridSize } : {}),
|
|
6409
6876
|
});
|
|
6877
|
+
const roughStr = JSON.stringify(input.roughness ?? 0);
|
|
6878
|
+
const seedStr = JSON.stringify(input.seed ?? 1);
|
|
6410
6879
|
return wrap('Medium: render in thread', ` if (typeof app.applyThreadPainting !== 'function') { return { success: false, error: 'thread medium unavailable — update FxTool' }; }
|
|
6411
6880
|
const entry = app.itemRegistry && app.itemRegistry.get(${S(input.itemId)});
|
|
6412
6881
|
const src = entry && entry.item;
|
|
@@ -6418,7 +6887,23 @@ ${guard}
|
|
|
6418
6887
|
}
|
|
6419
6888
|
const g = app.applyThreadPainting(${S(input.itemId)}, ${opts});
|
|
6420
6889
|
if (!g) { return { success: false, action: 'apply_thread', error: 'nothing stitchable here — thread needs a CLOSED path, a compound path, or a group containing them. An open stroke, a raster and an empty group all land here. (If you passed a stitch name, check it against list_stitches.)' }; }
|
|
6421
|
-
|
|
6890
|
+
// HAND WOBBLE, applied here rather than in the engine, which has no such
|
|
6891
|
+
// option. Every point of every stitch gets its own phase off the seed, so a
|
|
6892
|
+
// long contour wobbles along its whole length instead of shearing at one end,
|
|
6893
|
+
// and the same seed sews the same irregularities every run.
|
|
6894
|
+
const rough = ${roughStr};
|
|
6895
|
+
if (rough > 0 && g.children) {
|
|
6896
|
+
const rseed = ${seedStr};
|
|
6897
|
+
for (let i = 0; i < g.children.length; i++) {
|
|
6898
|
+
const line = g.children[i];
|
|
6899
|
+
if (!line || !line.segments) continue;
|
|
6900
|
+
for (let j = 0; j < line.segments.length; j++) {
|
|
6901
|
+
line.segments[j].point.x += Math.sin(rseed + i * 2.13 + j * 1.71) * rough * 0.4;
|
|
6902
|
+
line.segments[j].point.y += Math.cos(rseed + i * 3.47 + j * 2.29) * rough * 0.4;
|
|
6903
|
+
}
|
|
6904
|
+
}
|
|
6905
|
+
}
|
|
6906
|
+
return { success: true, action: 'apply_thread', groupId: (g.data && g.data.id) || null, stitches: g.children.length, roughness: rough };`);
|
|
6422
6907
|
}
|
|
6423
6908
|
case 'list_flow_fields':
|
|
6424
6909
|
return wrap('Medium: list flow fields', ` if (typeof app.listFlowFields !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
|
|
@@ -6735,6 +7220,761 @@ ${needWorld}
|
|
|
6735
7220
|
(function() {
|
|
6736
7221
|
if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
|
|
6737
7222
|
return { success: !!app.removeWorld3D() };
|
|
7223
|
+
})();`.trim();
|
|
7224
|
+
// --- Mesh authoring ---------------------------------------------------
|
|
7225
|
+
// extrudeToMesh / latheToMesh REFUSE by name when there is no world
|
|
7226
|
+
// ({ok:false, reason}), so their reason is passed through rather than
|
|
7227
|
+
// pre-empted with a second wording for the same condition. The facade
|
|
7228
|
+
// check stays, because a studio without the method is a different fault.
|
|
7229
|
+
case 'extrude_path':
|
|
7230
|
+
case 'lathe_path': {
|
|
7231
|
+
const fn = input.action === 'extrude_path' ? 'extrudeToMesh' : 'latheToMesh';
|
|
7232
|
+
const mesh = { ...(input.mesh ?? {}) };
|
|
7233
|
+
// rotY is RADIANS on this path — stored raw and handed to cos()/sin()
|
|
7234
|
+
// in the shader. rotYDegrees is the convenience spelling and is folded
|
|
7235
|
+
// in here so only one of them ever reaches the engine.
|
|
7236
|
+
if (typeof mesh.rotYDegrees === 'number') {
|
|
7237
|
+
if (mesh.rotY === undefined)
|
|
7238
|
+
mesh.rotY = mesh.rotYDegrees * Math.PI / 180;
|
|
7239
|
+
delete mesh.rotYDegrees;
|
|
7240
|
+
}
|
|
7241
|
+
return `
|
|
7242
|
+
// World3D: ${input.action} — a canvas path becomes real geometry
|
|
7243
|
+
(async function() {
|
|
7244
|
+
// @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
|
|
7245
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
|
|
7246
|
+
const r = await app.${fn}(${S(input.pathId)}, ${S(mesh)});
|
|
7247
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the world refused the mesh' }; }
|
|
7248
|
+
return { success: true, meshId: r.id, op: ${S(input.action === 'extrude_path' ? 'extrude' : 'lathe')}, sourceId: ${S(input.pathId)} };
|
|
7249
|
+
})();`.trim();
|
|
7250
|
+
}
|
|
7251
|
+
case 'list_meshes':
|
|
7252
|
+
return `
|
|
7253
|
+
// World3D: every authored mesh on the stage
|
|
7254
|
+
(function() {
|
|
7255
|
+
if (typeof app.listWorldMeshes !== 'function') { return { success: false, error: 'app.listWorldMeshes unavailable — update FxTool' }; }
|
|
7256
|
+
return { success: true, meshes: app.listWorldMeshes() };
|
|
7257
|
+
})();`.trim();
|
|
7258
|
+
case 'remove_mesh':
|
|
7259
|
+
return `
|
|
7260
|
+
// World3D: drop a mesh
|
|
7261
|
+
(function() {
|
|
7262
|
+
if (typeof app.removeWorldMesh !== 'function') { return { success: false, error: 'app.removeWorldMesh unavailable — update FxTool' }; }
|
|
7263
|
+
return { success: !!app.removeWorldMesh(${S(input.meshId)}) };
|
|
7264
|
+
})();`.trim();
|
|
7265
|
+
// --- Lights -----------------------------------------------------------
|
|
7266
|
+
// addWorldLight THROWS when there is no world, so this one is guarded
|
|
7267
|
+
// ahead of the call rather than reading a refusal back.
|
|
7268
|
+
case 'add_light':
|
|
7269
|
+
return `
|
|
7270
|
+
// World3D: add a point light — at most 8, and the ninth is refused by name
|
|
7271
|
+
(function() {
|
|
7272
|
+
if (typeof app.addWorldLight !== 'function') { return { success: false, error: 'app.addWorldLight unavailable — update FxTool' }; }
|
|
7273
|
+
${needWorld}
|
|
7274
|
+
const r = app.addWorldLight(${S(input.light ?? {})});
|
|
7275
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the light' }; }
|
|
7276
|
+
return { success: true, lightId: r.id, light: r.light };
|
|
7277
|
+
})();`.trim();
|
|
7278
|
+
case 'set_light':
|
|
7279
|
+
return `
|
|
7280
|
+
// World3D: edit a light in place
|
|
7281
|
+
(function() {
|
|
7282
|
+
if (typeof app.setWorldLight !== 'function') { return { success: false, error: 'app.setWorldLight unavailable — update FxTool' }; }
|
|
7283
|
+
return { success: !!app.setWorldLight(${S(input.lightId)}, ${S(input.light ?? {})}) };
|
|
7284
|
+
})();`.trim();
|
|
7285
|
+
case 'remove_light':
|
|
7286
|
+
return `
|
|
7287
|
+
// World3D: drop a light
|
|
7288
|
+
(function() {
|
|
7289
|
+
if (typeof app.removeWorldLight !== 'function') { return { success: false, error: 'app.removeWorldLight unavailable — update FxTool' }; }
|
|
7290
|
+
return { success: !!app.removeWorldLight(${S(input.lightId)}) };
|
|
7291
|
+
})();`.trim();
|
|
7292
|
+
case 'list_lights':
|
|
7293
|
+
return `
|
|
7294
|
+
// World3D: every light, with position, colour, intensity and range
|
|
7295
|
+
(function() {
|
|
7296
|
+
if (typeof app.listWorldLights !== 'function') { return { success: false, error: 'app.listWorldLights unavailable — update FxTool' }; }
|
|
7297
|
+
return { success: true, lights: app.listWorldLights() };
|
|
7298
|
+
})();`.trim();
|
|
7299
|
+
// --- Materials --------------------------------------------------------
|
|
7300
|
+
case 'add_material':
|
|
7301
|
+
return `
|
|
7302
|
+
// World3D: a named, SHARED surface — one edit restyles every object using it
|
|
7303
|
+
(function() {
|
|
7304
|
+
if (typeof app.addWorldMaterial !== 'function') { return { success: false, error: 'app.addWorldMaterial unavailable — update FxTool' }; }
|
|
7305
|
+
${needWorld}
|
|
7306
|
+
const r = app.addWorldMaterial(${S(input.material ?? {})});
|
|
7307
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the material' }; }
|
|
7308
|
+
return { success: true, materialId: r.id, material: r.material };
|
|
7309
|
+
})();`.trim();
|
|
7310
|
+
case 'set_material':
|
|
7311
|
+
return `
|
|
7312
|
+
// World3D: patch a material — every object referencing it changes next frame
|
|
7313
|
+
(function() {
|
|
7314
|
+
if (typeof app.setWorldMaterial !== 'function') { return { success: false, error: 'app.setWorldMaterial unavailable — update FxTool' }; }
|
|
7315
|
+
return { success: !!app.setWorldMaterial(${S(input.materialId)}, ${S(input.material ?? {})}) };
|
|
7316
|
+
})();`.trim();
|
|
7317
|
+
case 'remove_material':
|
|
7318
|
+
return `
|
|
7319
|
+
// World3D: drop a material
|
|
7320
|
+
(function() {
|
|
7321
|
+
if (typeof app.removeWorldMaterial !== 'function') { return { success: false, error: 'app.removeWorldMaterial unavailable — update FxTool' }; }
|
|
7322
|
+
return { success: !!app.removeWorldMaterial(${S(input.materialId)}) };
|
|
7323
|
+
})();`.trim();
|
|
7324
|
+
case 'list_materials':
|
|
7325
|
+
return `
|
|
7326
|
+
// World3D: every material, with the knobs that actually render
|
|
7327
|
+
(function() {
|
|
7328
|
+
if (typeof app.listWorldMaterials !== 'function') { return { success: false, error: 'app.listWorldMaterials unavailable — update FxTool' }; }
|
|
7329
|
+
return { success: true, materials: app.listWorldMaterials() };
|
|
7330
|
+
})();`.trim();
|
|
7331
|
+
// --- Import -----------------------------------------------------------
|
|
7332
|
+
// Both importers THROW when there is no world, so both are guarded ahead.
|
|
7333
|
+
case 'import_obj':
|
|
7334
|
+
case 'import_gltf': {
|
|
7335
|
+
const fn = input.action === 'import_obj' ? 'importOBJToWorld' : 'importGLTFToWorld';
|
|
7336
|
+
return `
|
|
7337
|
+
// World3D: ${input.action}
|
|
7338
|
+
(async function() {
|
|
7339
|
+
// @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
|
|
7340
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
|
|
7341
|
+
${needWorld}
|
|
7342
|
+
const r = await app.${fn}(${S(input.source)}, ${S(input.importOptions ?? {})});
|
|
7343
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the world refused the import' }; }
|
|
7344
|
+
return { success: true, meshIds: r.ids, ...r };
|
|
7345
|
+
})();`.trim();
|
|
7346
|
+
}
|
|
7347
|
+
case 'list_mesh_clips':
|
|
7348
|
+
return `
|
|
7349
|
+
// World3D: the animation clips an imported skinned mesh carries
|
|
7350
|
+
(function() {
|
|
7351
|
+
if (typeof app.listWorldMeshClips !== 'function') { return { success: false, error: 'app.listWorldMeshClips unavailable — update FxTool' }; }
|
|
7352
|
+
${needWorld}
|
|
7353
|
+
return { success: true, clips: app.listWorldMeshClips(${S(input.meshId)}) };
|
|
7354
|
+
})();`.trim();
|
|
7355
|
+
case 'set_mesh_clip':
|
|
7356
|
+
return `
|
|
7357
|
+
// World3D: choose which clip a skinned mesh plays
|
|
7358
|
+
(function() {
|
|
7359
|
+
if (typeof app.setWorldMeshClip !== 'function') { return { success: false, error: 'app.setWorldMeshClip unavailable — update FxTool' }; }
|
|
7360
|
+
${needWorld}
|
|
7361
|
+
return { success: !!app.setWorldMeshClip(${S(input.meshId)}, ${S(input.clip)}${input.crossfade !== undefined ? `, { crossfade: ${S(input.crossfade)} }` : ''}) };
|
|
7362
|
+
})();`.trim();
|
|
7363
|
+
// --- Navigation, picking, camera --------------------------------------
|
|
7364
|
+
// These facades answer `null` — or 0, for groundHeightAt — when there is
|
|
7365
|
+
// NO WORLD, which is indistinguishable from a real answer: ground really
|
|
7366
|
+
// can be at height 0, and a ray really can hit nothing. Guarded ahead so
|
|
7367
|
+
// "there is no world" never arrives disguised as a measurement.
|
|
7368
|
+
case 'set_nav_target':
|
|
7369
|
+
return `
|
|
7370
|
+
// World3D: point the pointer at the 2D canvas or the 3D world
|
|
7371
|
+
(function() {
|
|
7372
|
+
if (typeof app.setWorldNavTarget !== 'function') { return { success: false, error: 'app.setWorldNavTarget unavailable — update FxTool' }; }
|
|
7373
|
+
const r = app.setWorldNavTarget(${S(input.navTarget)});
|
|
7374
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the nav target was refused' }; }
|
|
7375
|
+
return { success: true, navTarget: ${S(input.navTarget)} };
|
|
7376
|
+
})();`.trim();
|
|
7377
|
+
case 'get_nav_target':
|
|
7378
|
+
return `
|
|
7379
|
+
// World3D: which layer the pointer currently drives
|
|
7380
|
+
(function() {
|
|
7381
|
+
if (typeof app.getWorldNavTarget !== 'function') { return { success: false, error: 'app.getWorldNavTarget unavailable — update FxTool' }; }
|
|
7382
|
+
return { success: true, navTarget: app.getWorldNavTarget() };
|
|
7383
|
+
})();`.trim();
|
|
7384
|
+
case 'ground_height':
|
|
7385
|
+
return `
|
|
7386
|
+
// World3D: the terrain height under a world (x, z)
|
|
7387
|
+
(function() {
|
|
7388
|
+
if (typeof app.groundHeightAt !== 'function') { return { success: false, error: 'app.groundHeightAt unavailable — update FxTool' }; }
|
|
7389
|
+
${needWorld}
|
|
7390
|
+
const p = ${S(input.point)};
|
|
7391
|
+
return { success: true, height: app.groundHeightAt(p.x, p.z !== undefined ? p.z : p.y) };
|
|
7392
|
+
})();`.trim();
|
|
7393
|
+
case 'raycast':
|
|
7394
|
+
return `
|
|
7395
|
+
// World3D: cast a ray and report what it hits
|
|
7396
|
+
(function() {
|
|
7397
|
+
if (typeof app.raycastWorld !== 'function') { return { success: false, error: 'app.raycastWorld unavailable — update FxTool' }; }
|
|
7398
|
+
${needWorld}
|
|
7399
|
+
const hit = app.raycastWorld(${S(input.origin)}, ${S(input.direction)}, {});
|
|
7400
|
+
// A null here means MISSED, not "no world" — the guard above already
|
|
7401
|
+
// separated those two, which is the whole reason it is there.
|
|
7402
|
+
return { success: true, hit: hit || null, missed: !hit };
|
|
7403
|
+
})();`.trim();
|
|
7404
|
+
case 'dolly_camera':
|
|
7405
|
+
return `
|
|
7406
|
+
// World3D: dolly the camera — >1 away, <1 closer
|
|
7407
|
+
(function() {
|
|
7408
|
+
if (typeof app.dollyWorldCamera !== 'function') { return { success: false, error: 'app.dollyWorldCamera unavailable — update FxTool' }; }
|
|
7409
|
+
${needWorld}
|
|
7410
|
+
return { success: true, camera: app.dollyWorldCamera(${S(input.multiplier)}) };
|
|
7411
|
+
})();`.trim();
|
|
7412
|
+
case 'pan_camera':
|
|
7413
|
+
return `
|
|
7414
|
+
// World3D: pan the camera across the ground, in SCREEN pixels
|
|
7415
|
+
(function() {
|
|
7416
|
+
if (typeof app.panWorldCamera !== 'function') { return { success: false, error: 'app.panWorldCamera unavailable — update FxTool' }; }
|
|
7417
|
+
${needWorld}
|
|
7418
|
+
return { success: true, camera: app.panWorldCamera(${S(input.dx)}, ${S(input.dy)}) };
|
|
7419
|
+
})();`.trim();
|
|
7420
|
+
case 'world_to_canvas':
|
|
7421
|
+
return `
|
|
7422
|
+
// World3D: project a world point onto the Paper canvas
|
|
7423
|
+
(function() {
|
|
7424
|
+
if (typeof app.worldToCanvas !== 'function') { return { success: false, error: 'app.worldToCanvas unavailable — update FxTool' }; }
|
|
7425
|
+
${needWorld}
|
|
7426
|
+
return { success: true, point: app.worldToCanvas(${S(input.point)}) };
|
|
7427
|
+
})();`.trim();
|
|
7428
|
+
case 'canvas_to_ground':
|
|
7429
|
+
return `
|
|
7430
|
+
// World3D: where a canvas (x, y) lands on the world's ground
|
|
7431
|
+
(function() {
|
|
7432
|
+
if (typeof app.canvasToGround !== 'function') { return { success: false, error: 'app.canvasToGround unavailable — update FxTool' }; }
|
|
7433
|
+
${needWorld}
|
|
7434
|
+
const p = ${S(input.point)};
|
|
7435
|
+
return { success: true, ground: app.canvasToGround(p.x, p.y) };
|
|
7436
|
+
})();`.trim();
|
|
7437
|
+
// --- Physics in the world ---------------------------------------------
|
|
7438
|
+
// addPhysicsBody3D and fireProjectile3D THROW without a world; the rest
|
|
7439
|
+
// return false, [] or a hollow {contacts:[],removed:[]} — each of which
|
|
7440
|
+
// is a plausible real answer. Both shapes are guarded the same way here.
|
|
7441
|
+
case 'add_body':
|
|
7442
|
+
return `
|
|
7443
|
+
// World3D: a body under gravity, colliding with the world
|
|
7444
|
+
(function() {
|
|
7445
|
+
if (typeof app.addPhysicsBody3D !== 'function') { return { success: false, error: 'app.addPhysicsBody3D unavailable — update FxTool' }; }
|
|
7446
|
+
${needWorld}
|
|
7447
|
+
const r = app.addPhysicsBody3D(${S(input.body)});
|
|
7448
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the world refused the body' }; }
|
|
7449
|
+
return { success: true, bodyId: r.id, ...r };
|
|
7450
|
+
})();`.trim();
|
|
7451
|
+
case 'fire_projectile':
|
|
7452
|
+
return `
|
|
7453
|
+
// World3D: fire a projectile
|
|
7454
|
+
(function() {
|
|
7455
|
+
if (typeof app.fireProjectile3D !== 'function') { return { success: false, error: 'app.fireProjectile3D unavailable — update FxTool' }; }
|
|
7456
|
+
${needWorld}
|
|
7457
|
+
const r = app.fireProjectile3D(${S(input.body)});
|
|
7458
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the projectile was refused' }; }
|
|
7459
|
+
return { success: true, ...r };
|
|
7460
|
+
})();`.trim();
|
|
7461
|
+
case 'remove_body':
|
|
7462
|
+
return `
|
|
7463
|
+
// World3D: drop a body
|
|
7464
|
+
(function() {
|
|
7465
|
+
if (typeof app.removePhysicsBody3D !== 'function') { return { success: false, error: 'app.removePhysicsBody3D unavailable — update FxTool' }; }
|
|
7466
|
+
${needWorld}
|
|
7467
|
+
return { success: !!app.removePhysicsBody3D(${S(input.bodyId)}) };
|
|
7468
|
+
})();`.trim();
|
|
7469
|
+
case 'list_bodies':
|
|
7470
|
+
return `
|
|
7471
|
+
// World3D: every physics body
|
|
7472
|
+
(function() {
|
|
7473
|
+
if (typeof app.listPhysicsBodies3D !== 'function') { return { success: false, error: 'app.listPhysicsBodies3D unavailable — update FxTool' }; }
|
|
7474
|
+
${needWorld}
|
|
7475
|
+
return { success: true, bodies: app.listPhysicsBodies3D() };
|
|
7476
|
+
})();`.trim();
|
|
7477
|
+
case 'step_physics':
|
|
7478
|
+
return `
|
|
7479
|
+
// World3D: advance the simulation, and report what touched what
|
|
7480
|
+
(function() {
|
|
7481
|
+
if (typeof app.stepPhysics3D !== 'function') { return { success: false, error: 'app.stepPhysics3D unavailable — update FxTool' }; }
|
|
7482
|
+
${needWorld}
|
|
7483
|
+
// Without a world this answers {contacts:[],removed:[]} — a perfectly
|
|
7484
|
+
// ordinary quiet step. The guard above is what keeps those apart.
|
|
7485
|
+
const r = app.stepPhysics3D(${S(input.dt)});
|
|
7486
|
+
return { success: true, contacts: r && r.contacts, removed: r && r.removed };
|
|
7487
|
+
})();`.trim();
|
|
7488
|
+
case 'impulse':
|
|
7489
|
+
return `
|
|
7490
|
+
// World3D: apply an impulse
|
|
7491
|
+
(function() {
|
|
7492
|
+
if (typeof app.applyPhysicsImpulse3D !== 'function') { return { success: false, error: 'app.applyPhysicsImpulse3D unavailable — update FxTool' }; }
|
|
7493
|
+
${needWorld}
|
|
7494
|
+
return { success: !!app.applyPhysicsImpulse3D(${S(input.bodyId)}, ${S(input.vector)}) };
|
|
7495
|
+
})();`.trim();
|
|
7496
|
+
case 'set_velocity':
|
|
7497
|
+
return `
|
|
7498
|
+
// World3D: set a body's velocity outright
|
|
7499
|
+
(function() {
|
|
7500
|
+
if (typeof app.setPhysicsVelocity3D !== 'function') { return { success: false, error: 'app.setPhysicsVelocity3D unavailable — update FxTool' }; }
|
|
7501
|
+
${needWorld}
|
|
7502
|
+
return { success: !!app.setPhysicsVelocity3D(${S(input.bodyId)}, ${S(input.vector)}) };
|
|
7503
|
+
})();`.trim();
|
|
7504
|
+
case 'line_of_sight':
|
|
7505
|
+
return `
|
|
7506
|
+
// World3D: can one point see another
|
|
7507
|
+
(function() {
|
|
7508
|
+
if (typeof app.worldLineOfSight !== 'function') { return { success: false, error: 'app.worldLineOfSight unavailable — update FxTool' }; }
|
|
7509
|
+
${needWorld}
|
|
7510
|
+
return { success: true, clear: app.worldLineOfSight(${S(input.from)}, ${S(input.to)}) };
|
|
7511
|
+
})();`.trim();
|
|
7512
|
+
case 'set_mesh_instances':
|
|
7513
|
+
return `
|
|
7514
|
+
// World3D: replace a mesh's instance data — six floats each
|
|
7515
|
+
(function() {
|
|
7516
|
+
if (typeof app.setWorldMeshInstances !== 'function') { return { success: false, error: 'app.setWorldMeshInstances unavailable — update FxTool' }; }
|
|
7517
|
+
${needWorld}
|
|
7518
|
+
// x, y, z, scale, rotY (RADIANS, as everywhere on this path), variant.
|
|
7519
|
+
return { success: !!app.setWorldMeshInstances(${S(input.meshId)}, ${S(input.instances)}) };
|
|
7520
|
+
})();`.trim();
|
|
7521
|
+
}
|
|
7522
|
+
}
|
|
7523
|
+
/**
|
|
7524
|
+
* pinepaper_design_system — answered in this server, not in the browser.
|
|
7525
|
+
*
|
|
7526
|
+
* Only `compose` with `draw` produces code at all: the tokens, the easings
|
|
7527
|
+
* and the style list are computed here and returned by the handler, because
|
|
7528
|
+
* a design decision does not need the canvas to be open. When a scene IS
|
|
7529
|
+
* drawn, it arrives as a finished list of create calls — the layout was
|
|
7530
|
+
* already decided by a pure function, so nothing is left for the page to
|
|
7531
|
+
* work out.
|
|
7532
|
+
*/
|
|
7533
|
+
generateDesignCompose(scene, ops, style) {
|
|
7534
|
+
const S = (v) => JSON.stringify(v);
|
|
7535
|
+
return `
|
|
7536
|
+
// Design: compose a ${style} scene — ${ops.length} items, laid out server-side
|
|
7537
|
+
(function() {
|
|
7538
|
+
if (typeof app.create !== 'function') { return { success: false, error: 'app.create unavailable — update FxTool' }; }
|
|
7539
|
+
const size = ${S({ width: scene.width, height: scene.height })};
|
|
7540
|
+
if (typeof app.setCanvasSize === 'function') { try { app.setCanvasSize(size.width, size.height); } catch (_) { /* a fixed canvas is not a failure */ } }
|
|
7541
|
+
${scene.backgroundHex ? `if (typeof app.setBackgroundColor === 'function') { try { app.setBackgroundColor(${S(scene.backgroundHex)}); } catch (_) { /* ditto */ } }` : '// this style paints no background of its own'}
|
|
7542
|
+
const ops = ${S(ops)};
|
|
7543
|
+
const ids = [];
|
|
7544
|
+
const failed = [];
|
|
7545
|
+
for (const op of ops) {
|
|
7546
|
+
const { type, note, name, ...props } = op;
|
|
7547
|
+
try {
|
|
7548
|
+
const item = app.create(type, props);
|
|
7549
|
+
if (item) { ids.push(item.data && item.data.id); } else { failed.push({ name: name, type: type, reason: 'create returned nothing' }); }
|
|
7550
|
+
} catch (e) { failed.push({ name: name, type: type, reason: (e && e.message) || String(e) }); }
|
|
7551
|
+
}
|
|
7552
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
7553
|
+
// A partial composition is reported as one. Half a poster that claims
|
|
7554
|
+
// success is worse than a poster that says which four elements are missing.
|
|
7555
|
+
return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed };
|
|
7556
|
+
})();`.trim();
|
|
7557
|
+
}
|
|
7558
|
+
/**
|
|
7559
|
+
* pinepaper_stick — the vendored stick-figure kit.
|
|
7560
|
+
*
|
|
7561
|
+
* Both facades take one options bag, so the schema's named fields are
|
|
7562
|
+
* reassembled into it. Naming them individually is the point: the kit's
|
|
7563
|
+
* options were discoverable only by reading FxTool's source.
|
|
7564
|
+
*/
|
|
7565
|
+
generateStick(input) {
|
|
7566
|
+
const S = (v) => JSON.stringify(v);
|
|
7567
|
+
const { action, ...rest } = input;
|
|
7568
|
+
const opts = {};
|
|
7569
|
+
for (const [k, v] of Object.entries(rest))
|
|
7570
|
+
if (v !== undefined)
|
|
7571
|
+
opts[k] = v;
|
|
7572
|
+
const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
|
|
7573
|
+
return `
|
|
7574
|
+
// Stick: ${action}
|
|
7575
|
+
(async function() {
|
|
7576
|
+
// @engine-methods stickFigure stickSet
|
|
7577
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool (the stick kit is vendored from mcp-cloud)' }; }
|
|
7578
|
+
const r = await app.${fn}(${S(opts)});
|
|
7579
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the stick kit produced nothing' }; }
|
|
7580
|
+
return { success: true, ...r };
|
|
7581
|
+
})();`.trim();
|
|
7582
|
+
}
|
|
7583
|
+
/**
|
|
7584
|
+
* pinepaper_story — prose becomes a scene.
|
|
7585
|
+
*
|
|
7586
|
+
* `distill` deliberately draws nothing. A caller should be able to read what
|
|
7587
|
+
* the distiller made of the text and edit it before a scene is assembled
|
|
7588
|
+
* from it, rather than discovering the reading by looking at the result.
|
|
7589
|
+
*/
|
|
7590
|
+
generateStory(input) {
|
|
7591
|
+
const S = (v) => JSON.stringify(v);
|
|
7592
|
+
const opts = S(input.options ?? {});
|
|
7593
|
+
switch (input.action) {
|
|
7594
|
+
case 'distill':
|
|
7595
|
+
return `
|
|
7596
|
+
// Story: reduce prose to beats, drawing NOTHING
|
|
7597
|
+
(async function() {
|
|
7598
|
+
if (typeof app.distillArticle !== 'function') { return { success: false, error: 'app.distillArticle unavailable — update FxTool' }; }
|
|
7599
|
+
const beats = await app.distillArticle(${S(input.text)});
|
|
7600
|
+
if (!beats) { return { success: false, error: 'the distiller made nothing of that text' }; }
|
|
7601
|
+
return { success: true, beats: beats };
|
|
7602
|
+
})();`.trim();
|
|
7603
|
+
case 'from_text':
|
|
7604
|
+
return `
|
|
7605
|
+
// Story: distill and assemble in one call
|
|
7606
|
+
(async function() {
|
|
7607
|
+
if (typeof app.storyFromText !== 'function') { return { success: false, error: 'app.storyFromText unavailable — update FxTool' }; }
|
|
7608
|
+
const r = await app.storyFromText(${S(input.text)}, ${opts});
|
|
7609
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the story did not assemble' }; }
|
|
7610
|
+
return { success: true, ...r };
|
|
7611
|
+
})();`.trim();
|
|
7612
|
+
case 'apply_spec':
|
|
7613
|
+
return `
|
|
7614
|
+
// Story: assemble a spec you already have
|
|
7615
|
+
(async function() {
|
|
7616
|
+
if (typeof app.applyStorySpec !== 'function') { return { success: false, error: 'app.applyStorySpec unavailable — update FxTool' }; }
|
|
7617
|
+
const r = await app.applyStorySpec(${S(input.spec)}, ${opts});
|
|
7618
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the spec did not assemble' }; }
|
|
7619
|
+
return { success: true, ...r };
|
|
7620
|
+
})();`.trim();
|
|
7621
|
+
case 'plan_book':
|
|
7622
|
+
return `
|
|
7623
|
+
// Story: lay images out as pages
|
|
7624
|
+
(function() {
|
|
7625
|
+
if (typeof app.planBook !== 'function') { return { success: false, error: 'app.planBook unavailable — update FxTool' }; }
|
|
7626
|
+
const plan = app.planBook(${S(input.images)}, ${opts});
|
|
7627
|
+
if (!plan) { return { success: false, error: 'no plan came back for those images' }; }
|
|
7628
|
+
return { success: true, plan: plan };
|
|
7629
|
+
})();`.trim();
|
|
7630
|
+
}
|
|
7631
|
+
}
|
|
7632
|
+
/**
|
|
7633
|
+
* pinepaper_interchange — Lottie, dotLottie, GLB, BVH, PNG sequence.
|
|
7634
|
+
*
|
|
7635
|
+
* Three of these refuse through `console.warn` and a falsy return, which the
|
|
7636
|
+
* production build strips — exportGLB with no perspective objects and
|
|
7637
|
+
* exportBVH with no rigging system both do exactly that. Over MCP there is no
|
|
7638
|
+
* console to read, so the PRECONDITION is checked here and named, rather than
|
|
7639
|
+
* letting a caller receive nothing and guess why.
|
|
7640
|
+
*/
|
|
7641
|
+
// @engine-methods exportLottie exportDotLottie exportEngine.exportToStore exportEngine.readExport exportEngine.releaseExport
|
|
7642
|
+
generateInterchange(input) {
|
|
7643
|
+
const S = (v) => JSON.stringify(v);
|
|
7644
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7645
|
+
const opts = S(input.options ?? {});
|
|
7646
|
+
switch (input.action) {
|
|
7647
|
+
case 'export_lottie':
|
|
7648
|
+
case 'export_dotlottie': {
|
|
7649
|
+
const fn = input.action === 'export_lottie' ? 'exportLottie' : 'exportDotLottie';
|
|
7650
|
+
return `
|
|
7651
|
+
// Interchange: ${fn}
|
|
7652
|
+
(async function() {
|
|
7653
|
+
${guard(fn)}
|
|
7654
|
+
const out = await app.${fn}(${opts});
|
|
7655
|
+
if (!out) { return { success: false, error: '${fn} produced nothing — the scene may have no animatable content' }; }
|
|
7656
|
+
return { success: true, format: ${S(input.action === 'export_lottie' ? 'lottie' : 'dotlottie')}, data: out };
|
|
7657
|
+
})();`.trim();
|
|
7658
|
+
}
|
|
7659
|
+
case 'import_lottie':
|
|
7660
|
+
return `
|
|
7661
|
+
// Interchange: a Lottie back onto the canvas
|
|
7662
|
+
(async function() {
|
|
7663
|
+
${guard('importLottie')}
|
|
7664
|
+
// importLottie logs and returns falsy when the importer is absent — a
|
|
7665
|
+
// stripped console makes that indistinguishable from an empty animation.
|
|
7666
|
+
const r = await app.importLottie(${S(input.data)}, ${opts});
|
|
7667
|
+
if (!r) { return { success: false, error: 'the Lottie did not import — it may be malformed, or this build has no Lottie importer' }; }
|
|
7668
|
+
return { success: true, imported: r };
|
|
7669
|
+
})();`.trim();
|
|
7670
|
+
case 'export_glb':
|
|
7671
|
+
return `
|
|
7672
|
+
// Interchange: GLB of the perspective 3D objects
|
|
7673
|
+
(async function() {
|
|
7674
|
+
${guard('exportGLB')}
|
|
7675
|
+
// exportGLB warns and bails when nothing perspective exists. The warning is
|
|
7676
|
+
// stripped in production, so the precondition is checked here instead.
|
|
7677
|
+
const has3d = app._initialized && app._initialized.has && app._initialized.has('threeD');
|
|
7678
|
+
if (!has3d) { return { success: false, error: 'no perspective objects to export — create one with createObject3D first' }; }
|
|
7679
|
+
const out = await app.exportGLB(${opts});
|
|
7680
|
+
if (!out) { return { success: false, error: 'GLB export produced nothing' }; }
|
|
7681
|
+
return { success: true, format: 'glb', data: out };
|
|
7682
|
+
})();`.trim();
|
|
7683
|
+
case 'export_bvh':
|
|
7684
|
+
return `
|
|
7685
|
+
// Interchange: BVH of one skeleton's motion
|
|
7686
|
+
(async function() {
|
|
7687
|
+
${guard('exportBVH')}
|
|
7688
|
+
// Same shape as GLB: exportBVH warns and bails without a RiggingSystem.
|
|
7689
|
+
const rigged = app._initialized && app._initialized.has && app._initialized.has('riggingSystem');
|
|
7690
|
+
if (!rigged) { return { success: false, error: 'no rig to export — create a skeleton first (pinepaper_rigging)' }; }
|
|
7691
|
+
const out = await app.exportBVH(${S(input.skeletonId)}, ${opts});
|
|
7692
|
+
if (!out) { return { success: false, error: 'BVH export produced nothing for skeleton ' + ${S(input.skeletonId)} }; }
|
|
7693
|
+
return { success: true, format: 'bvh', skeletonId: ${S(input.skeletonId)}, data: out };
|
|
7694
|
+
})();`.trim();
|
|
7695
|
+
case 'export_png_sequence':
|
|
7696
|
+
return `
|
|
7697
|
+
// Interchange: one PNG per frame
|
|
7698
|
+
(async function() {
|
|
7699
|
+
${guard('exportPNGSequence')}
|
|
7700
|
+
const out = await app.exportPNGSequence(${opts});
|
|
7701
|
+
if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
|
|
7702
|
+
return { success: true, format: 'png-sequence', result: out };
|
|
7703
|
+
})();`.trim();
|
|
7704
|
+
}
|
|
7705
|
+
}
|
|
7706
|
+
/**
|
|
7707
|
+
* pinepaper_sound — synthesis, and the drawing that is the same object.
|
|
7708
|
+
*
|
|
7709
|
+
* Every facade here rides `app.audioGraph`, which is lazily built, so each
|
|
7710
|
+
* action guards the METHOD rather than assuming the graph. The canvas half
|
|
7711
|
+
* (`create`, `timbre_from_path`) goes through PinePaper directly.
|
|
7712
|
+
*/
|
|
7713
|
+
generateSound(input) {
|
|
7714
|
+
const S = (v) => JSON.stringify(v);
|
|
7715
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7716
|
+
const simple = (fn, args, key, label) => `
|
|
7717
|
+
// Sound: ${label}
|
|
7718
|
+
(function() {
|
|
7719
|
+
${guard(fn)}
|
|
7720
|
+
return { success: true, ${key}: app.${fn}(${args}) };
|
|
7721
|
+
})();`.trim();
|
|
7722
|
+
switch (input.action) {
|
|
7723
|
+
case 'list_instruments': return simple('listInstruments', '', 'instruments', 'the instrument catalogue');
|
|
7724
|
+
case 'list_percussion': return simple('listPercussion', '', 'percussion', 'the percussion catalogue');
|
|
7725
|
+
case 'list_sfx': return simple('listSfx', '', 'sfx', 'the SFX catalogue');
|
|
7726
|
+
case 'play_tone': return simple('playTone', `${S(input.note)}, ${S(input.options ?? {})}`, 'played', `tone ${input.note}`);
|
|
7727
|
+
case 'play_chord': return simple('playChord', `${S(input.root)}, ${S(input.chord)}, ${S(input.options ?? {})}`, 'played', `${input.chord} chord on ${input.root}`);
|
|
7728
|
+
case 'chord_frequencies': return simple('chordFrequencies', `${S(input.root)}, ${S(input.chord)}, ${S(input.options ?? {})}`, 'frequencies', `the Hz of ${input.chord} on ${input.root}`);
|
|
7729
|
+
case 'play_percussion': return simple('playPercussion', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `percussion ${input.name}`);
|
|
7730
|
+
case 'play_sfx': return simple('playSfx', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `sfx ${input.name}`);
|
|
7731
|
+
case 'play_spec': return simple('playSound', S(input.spec), 'played', 'a raw spec');
|
|
7732
|
+
case 'play_from_text': return simple('playSoundFromText', S(input.text), 'played', `"${input.text}"`);
|
|
7733
|
+
case 'from_text':
|
|
7734
|
+
return `
|
|
7735
|
+
// Sound: resolve "${input.text}" to a spec WITHOUT playing it
|
|
7736
|
+
(function() {
|
|
7737
|
+
${guard('buildSoundFromText')}
|
|
7738
|
+
const spec = app.buildSoundFromText(${S(input.text)});
|
|
7739
|
+
// A description the resolver cannot read comes back empty rather than
|
|
7740
|
+
// throwing. Saying so beats handing back a spec that plays silence.
|
|
7741
|
+
if (!spec) { return { success: false, error: 'could not resolve ' + ${S(input.text)} + ' into a sound' }; }
|
|
7742
|
+
return { success: true, spec: spec };
|
|
7743
|
+
})();`.trim();
|
|
7744
|
+
// DEFINING A SOUND. The engine's three tables are "a curated starting set,
|
|
7745
|
+
// not a claim that music contains six instruments" — its words. Anything
|
|
7746
|
+
// it does not model can be registered at runtime and is then first-class:
|
|
7747
|
+
// listInstruments() reads the LIVE table, so a defined instrument shows
|
|
7748
|
+
// up in list_instruments and plays by name like any built-in.
|
|
7749
|
+
//
|
|
7750
|
+
// The canonical name is RETURNED rather than assumed. Registration
|
|
7751
|
+
// normalises to [a-z][a-z0-9_-]{0,31}, so a model that registers
|
|
7752
|
+
// 'Rhodes' and then plays 'Rhodes' is playing something that does not
|
|
7753
|
+
// exist — the name it actually got is 'rhodes', and it needs to be told.
|
|
7754
|
+
case 'define_instrument':
|
|
7755
|
+
case 'define_percussion':
|
|
7756
|
+
case 'define_sfx': {
|
|
7757
|
+
const fn = input.action === 'define_instrument' ? 'registerInstrument'
|
|
7758
|
+
: input.action === 'define_percussion' ? 'registerPercussion' : 'registerSfx';
|
|
7759
|
+
const listFn = input.action === 'define_instrument' ? 'listInstruments'
|
|
7760
|
+
: input.action === 'define_percussion' ? 'listPercussion' : 'listSfx';
|
|
7761
|
+
const spec = {
|
|
7762
|
+
...(input.partials ? { partials: input.partials } : {}),
|
|
7763
|
+
...(input.envelope ? { envelope: input.envelope } : {}),
|
|
7764
|
+
...(input.gain !== undefined ? { gain: input.gain } : {}),
|
|
7765
|
+
...(input.aliases ? { aliases: input.aliases } : {}),
|
|
7766
|
+
...(input.noise !== undefined ? { noise: input.noise } : {}),
|
|
7767
|
+
...(input.noiseFreq !== undefined ? { noiseFreq: input.noiseFreq } : {}),
|
|
7768
|
+
...(input.hz !== undefined ? { hz: input.hz } : {}),
|
|
7769
|
+
...(input.pitch ? { pitch: input.pitch } : {}),
|
|
7770
|
+
};
|
|
7771
|
+
// @engine-methods registerInstrument registerPercussion registerSfx
|
|
7772
|
+
// The guard below is INTERPOLATED — `guard(fn)` takes a variable, not a
|
|
7773
|
+
// literal — so sync-engine-methods.mjs cannot read the names out of it
|
|
7774
|
+
// and the studio probe would report a studio as complete while never
|
|
7775
|
+
// checking the method a caller needs. It extracted only
|
|
7776
|
+
// renderSoundtrackWav until this line existed.
|
|
7777
|
+
return `
|
|
7778
|
+
// Sound: define ${input.name} at runtime
|
|
7779
|
+
(function() {
|
|
7780
|
+
${guard(fn)}
|
|
7781
|
+
var name;
|
|
7782
|
+
// The engine THROWS on an unusable name or a silent spec, with a message
|
|
7783
|
+
// naming what was wrong. That is the useful answer, so it is caught and
|
|
7784
|
+
// returned rather than allowed to surface as a bridge error with no subject.
|
|
7785
|
+
try { name = app.${fn}(${S(input.name)}, ${S(spec)}); }
|
|
7786
|
+
catch (e) { return { success: false, error: (e && e.message) || String(e) }; }
|
|
7787
|
+
var listed = typeof app.${listFn} === 'function' ? app.${listFn}() : null;
|
|
7788
|
+
var found = Array.isArray(listed)
|
|
7789
|
+
? listed.some(function (x) { return (x && x.name) === name; })
|
|
7790
|
+
: null;
|
|
7791
|
+
return {
|
|
7792
|
+
success: true,
|
|
7793
|
+
// The name the engine CHOSE, which is what plays. Registering 'Rhodes'
|
|
7794
|
+
// returns 'rhodes'.
|
|
7795
|
+
canonicalName: name,
|
|
7796
|
+
requestedName: ${S(input.name)},
|
|
7797
|
+
normalised: name !== ${S(input.name)},
|
|
7798
|
+
// Defined but unlistable would be a capability nobody can name. Proven per
|
|
7799
|
+
// call rather than assumed, because it is one table lookup.
|
|
7800
|
+
listed: found,
|
|
7801
|
+
};
|
|
7802
|
+
})();`.trim();
|
|
7803
|
+
}
|
|
7804
|
+
case 'render_soundtrack': {
|
|
7805
|
+
const opts = {
|
|
7806
|
+
...(typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
|
|
7807
|
+
...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}),
|
|
7808
|
+
...(input.bitDepth !== undefined ? { bitDepth: input.bitDepth } : {}),
|
|
7809
|
+
};
|
|
7810
|
+
return `
|
|
7811
|
+
// Sound: mix every placed sound to a WAV, offline
|
|
7812
|
+
(function() {
|
|
7813
|
+
${guard('renderSoundtrackWav')}
|
|
7814
|
+
var r = app.renderSoundtrackWav(${S(opts)});
|
|
7815
|
+
// BARE NULL MEANS "NO SOUNDS PLACED". Returned for an empty scene and for a
|
|
7816
|
+
// scene whose sounds all failed to mix — indistinguishable from a malfunction
|
|
7817
|
+
// unless it is named, and "the export produced nothing" is the report that
|
|
7818
|
+
// sends someone debugging the exporter instead of placing a sound.
|
|
7819
|
+
if (!r || !r.wav) {
|
|
7820
|
+
return { success: false, error: 'no sounds are placed on this scene, so there is no soundtrack to render. Create one with action "create", or place an existing sound with "set_placement".' };
|
|
7821
|
+
}
|
|
7822
|
+
var bytes = r.wav;
|
|
7823
|
+
// Uint8Array cannot cross the bridge; base64 in chunks, because
|
|
7824
|
+
// String.fromCharCode.apply over a multi-megabyte array blows the stack.
|
|
7825
|
+
var bin = '';
|
|
7826
|
+
for (var i = 0; i < bytes.length; i += 8192) {
|
|
7827
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
7828
|
+
}
|
|
7829
|
+
return {
|
|
7830
|
+
success: true,
|
|
7831
|
+
wavBase64: btoa(bin),
|
|
7832
|
+
byteLength: bytes.length,
|
|
7833
|
+
duration: r.duration,
|
|
7834
|
+
sampleRate: r.sampleRate,
|
|
7835
|
+
sounds: r.sounds,
|
|
7836
|
+
placed: r.placed,
|
|
7837
|
+
// DROPPED IS THE SECOND SILENT REFUSAL. A mix with placed > 0 and dropped
|
|
7838
|
+
// > 0 is a valid WAV that is quietly missing sounds — the same shape as an
|
|
7839
|
+
// item list capped below its own itemCount. Reported only when it happened,
|
|
7840
|
+
// so the marker stays meaningful.
|
|
7841
|
+
...(r.dropped > 0 ? { dropped: r.dropped, incomplete:
|
|
7842
|
+
'mixed ' + r.placed + ' sound(s); ' + r.dropped + ' could not be rendered and are absent from this file' } : {}),
|
|
7843
|
+
};
|
|
7844
|
+
})();`.trim();
|
|
7845
|
+
}
|
|
7846
|
+
case 'create':
|
|
7847
|
+
return `
|
|
7848
|
+
// Sound: draw it AS a waveform path — the item and the sound are one thing
|
|
7849
|
+
(function() {
|
|
7850
|
+
${guard('createSound')}
|
|
7851
|
+
const item = app.createSound(${S(input.spec ?? {})}, ${S(input.visual ?? {})});
|
|
7852
|
+
if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
|
|
7853
|
+
return { success: true, itemId: item.data && item.data.id, itemType: 'sound' };
|
|
7854
|
+
})();`.trim();
|
|
7855
|
+
case 'timbre_from_path':
|
|
7856
|
+
return `
|
|
7857
|
+
// Sound: read a drawn path's shape as harmonic content
|
|
7858
|
+
(function() {
|
|
7859
|
+
${guard('timbreFromPath')}
|
|
7860
|
+
const item = app.getItemById ? app.getItemById(${S(input.itemId)}) : null;
|
|
7861
|
+
// timbreFromPath answers a flat {partials:[{h:1,amp:1}]} for anything that is
|
|
7862
|
+
// not a path with length — a real timbre and a refusal look identical, so the
|
|
7863
|
+
// item is checked HERE instead of reading that back as an answer.
|
|
7864
|
+
if (!item) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
7865
|
+
if (typeof item.getPointAt !== 'function' || !(item.length > 0)) {
|
|
7866
|
+
return { success: false, error: 'timbre needs a path with length — ' + ${S(input.itemId)} + ' is not one' };
|
|
7867
|
+
}
|
|
7868
|
+
return { success: true, timbre: app.timbreFromPath(item, ${S(input.samples ?? 256)}) };
|
|
7869
|
+
})();`.trim();
|
|
7870
|
+
case 'set_placement': return simple('setSoundPlacement', `${S(input.itemId)}, ${S(input.placement ?? {})}`, 'placed', 'placement');
|
|
7871
|
+
case 'remove': return simple('removeSound', S(input.itemId), 'removed', 'remove');
|
|
7872
|
+
case 'stop_all': return simple('stopSounds', '', 'stopped', 'stop everything');
|
|
7873
|
+
}
|
|
7874
|
+
}
|
|
7875
|
+
/**
|
|
7876
|
+
* pinepaper_motion — the generators' Animation knob for any group.
|
|
7877
|
+
*
|
|
7878
|
+
* animateItems returns `{error}` on failure, NOT `{ok:false, reason}` like
|
|
7879
|
+
* the path facades — reading the wrong key here turns "could not attach
|
|
7880
|
+
* \"ripple\" (frame-callback limit reached?)" into undefined.
|
|
7881
|
+
*/
|
|
7882
|
+
generateMotion(input) {
|
|
7883
|
+
const S = (v) => JSON.stringify(v);
|
|
7884
|
+
if (input.action === 'list') {
|
|
7885
|
+
return `
|
|
7886
|
+
// The engine's own motion catalogue — motions, waveforms, origins, knobs
|
|
7887
|
+
(async function() {
|
|
7888
|
+
if (typeof app.listGeneratorMotions !== 'function') { return { success: false, error: 'app.listGeneratorMotions unavailable — update FxTool' }; }
|
|
7889
|
+
const c = await app.listGeneratorMotions();
|
|
7890
|
+
return { success: true, ...c };
|
|
7891
|
+
})();`.trim();
|
|
7892
|
+
}
|
|
7893
|
+
const target = input.itemIds && input.itemIds.length ? S(input.itemIds) : S(input.itemId);
|
|
7894
|
+
const opts = { animation: input.motion };
|
|
7895
|
+
for (const k of ['speed', 'intensity', 'waveform', 'origin', 'seed']) {
|
|
7896
|
+
if (input[k] !== undefined)
|
|
7897
|
+
opts[k] = input[k];
|
|
7898
|
+
}
|
|
7899
|
+
return `
|
|
7900
|
+
// Motion: ${input.motion} on ${input.itemIds?.length ? `${input.itemIds.length} items` : input.itemId}
|
|
7901
|
+
(async function() {
|
|
7902
|
+
if (typeof app.animateItems !== 'function') { return { success: false, error: 'app.animateItems unavailable — update FxTool' }; }
|
|
7903
|
+
const r = await app.animateItems(${target}, ${S(opts)});
|
|
7904
|
+
// An unknown motion comes back with the known list attached, so a caller can
|
|
7905
|
+
// correct itself without a second round trip.
|
|
7906
|
+
if (!r || r.error) { return { success: false, error: (r && r.error) || 'the motion did not attach', motions: r && r.motions }; }
|
|
7907
|
+
return { success: true, motion: ${S(input.motion)} };
|
|
7908
|
+
})();`.trim();
|
|
7909
|
+
}
|
|
7910
|
+
/**
|
|
7911
|
+
* pinepaper_path — the destructive path operations.
|
|
7912
|
+
*
|
|
7913
|
+
* Every facade here returns `{ok:false, reason}` and names its own refusal
|
|
7914
|
+
* ("a boolean needs at least two paths", "that path has no stroke to
|
|
7915
|
+
* outline"), so the reason is passed through rather than reworded. A second
|
|
7916
|
+
* wording for one condition is the thing to avoid, not the guard.
|
|
7917
|
+
*/
|
|
7918
|
+
generatePathOp(input) {
|
|
7919
|
+
const S = (v) => JSON.stringify(v);
|
|
7920
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7921
|
+
const pass = (expr) => `
|
|
7922
|
+
const r = ${expr};
|
|
7923
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the engine refused the operation' }; }
|
|
7924
|
+
return { success: true, ...r };`;
|
|
7925
|
+
switch (input.action) {
|
|
7926
|
+
case 'boolean':
|
|
7927
|
+
return `
|
|
7928
|
+
// Path boolean: ${input.op} — CONSUMES its operands and leaves one result
|
|
7929
|
+
(function() {
|
|
7930
|
+
${guard('booleanOp')}${pass(`app.booleanOp(${S(input.itemIds)}, ${S(input.op)})`)}
|
|
7931
|
+
})();`.trim();
|
|
7932
|
+
case 'simplify':
|
|
7933
|
+
return `
|
|
7934
|
+
// Simplify: fewer segments, same shape
|
|
7935
|
+
(function() {
|
|
7936
|
+
${guard('simplifyPath')}${pass(`app.simplifyPath(${S(input.itemId)}${input.tolerance !== undefined ? `, ${S(input.tolerance)}` : ''})`)}
|
|
7937
|
+
})();`.trim();
|
|
7938
|
+
case 'outline_stroke':
|
|
7939
|
+
return `
|
|
7940
|
+
// Outline stroke: a stroked line becomes a filled shape of the same width
|
|
7941
|
+
(function() {
|
|
7942
|
+
${guard('outlineStroke')}${pass(`app.outlineStroke(${S(input.itemId)}, ${S(input.tolerance !== undefined ? { tolerance: input.tolerance } : {})})`)}
|
|
7943
|
+
})();`.trim();
|
|
7944
|
+
case 'toggle_closed':
|
|
7945
|
+
return `
|
|
7946
|
+
// Open or close the path
|
|
7947
|
+
(function() {
|
|
7948
|
+
${guard('togglePathClosed')}${pass(`app.togglePathClosed(${S(input.itemId)})`)}
|
|
7949
|
+
})();`.trim();
|
|
7950
|
+
case 'pattern':
|
|
7951
|
+
return `
|
|
7952
|
+
// Pattern: repeat the item — ${input.kind}
|
|
7953
|
+
(function() {
|
|
7954
|
+
${guard('patternFrom')}${pass(`app.patternFrom(${S(input.itemId)}, ${S(input.kind)}, ${S(input.pattern ?? {})})`)}
|
|
7955
|
+
})();`.trim();
|
|
7956
|
+
case 'get_geometry':
|
|
7957
|
+
return `
|
|
7958
|
+
// Read the path's segments back
|
|
7959
|
+
(function() {
|
|
7960
|
+
${guard('getPathGeometry')}
|
|
7961
|
+
const g = app.getPathGeometry(${S(input.itemId)}, {});
|
|
7962
|
+
// null means no such item — the one facade here that does NOT name its
|
|
7963
|
+
// refusal, so this is the one place a wording is supplied rather than passed.
|
|
7964
|
+
if (!g) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
7965
|
+
return { success: true, geometry: g };
|
|
7966
|
+
})();`.trim();
|
|
7967
|
+
case 'set_locked':
|
|
7968
|
+
return `
|
|
7969
|
+
// Lock or unlock — a locked item is also unselectable and undraggable
|
|
7970
|
+
(function() {
|
|
7971
|
+
${guard('setItemLocked')}${pass(`app.setItemLocked(${S(input.itemId)}, ${S(input.locked)})`)}
|
|
7972
|
+
})();`.trim();
|
|
7973
|
+
case 'unlock_all':
|
|
7974
|
+
return `
|
|
7975
|
+
// Unlock everything on the canvas
|
|
7976
|
+
(function() {
|
|
7977
|
+
${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
|
|
6738
7978
|
})();`.trim();
|
|
6739
7979
|
}
|
|
6740
7980
|
}
|
|
@@ -7197,6 +8437,21 @@ ${needWorld}
|
|
|
7197
8437
|
* object. Guards on the app method so old FxTool builds degrade gracefully.
|
|
7198
8438
|
*/
|
|
7199
8439
|
generateEquationPath(input) {
|
|
8440
|
+
// An ODE is INTEGRATED, not plotted: the caller gets the trajectory back as
|
|
8441
|
+
// data rather than a drawn path, so it can be inspected, fed to a path, or
|
|
8442
|
+
// used to drive keyframes. Branching here rather than in a separate tool
|
|
8443
|
+
// keeps one place that knows the equation vocabulary.
|
|
8444
|
+
if (input.solveOde) {
|
|
8445
|
+
const ode = input.solveOde;
|
|
8446
|
+
return `
|
|
8447
|
+
// Integrate an ODE and return the SOLUTION — nothing is drawn
|
|
8448
|
+
(async function() {
|
|
8449
|
+
if (typeof app.solveODE !== 'function') { return { success: false, error: 'app.solveODE unavailable — update FxTool' }; }
|
|
8450
|
+
const sol = await app.solveODE(${JSON.stringify({ equations: ode.equations, initialState: ode.initialState, tEnd: ode.tEnd, dt: ode.dt })}, ${JSON.stringify(ode.method ?? 'rk4')});
|
|
8451
|
+
if (!sol) { return { success: false, error: 'the solver returned nothing — check the equations and the initial state length' }; }
|
|
8452
|
+
return { success: true, method: ${JSON.stringify(ode.method ?? 'rk4')}, solution: sol };
|
|
8453
|
+
})();`.trim();
|
|
8454
|
+
}
|
|
7200
8455
|
const optsJson = JSON.stringify(input);
|
|
7201
8456
|
const kindJson = JSON.stringify(input.kind);
|
|
7202
8457
|
return `
|