@pinepaper.studio/mcp-server 1.6.6 → 1.6.8
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 +24 -1
- package/README.es.md +24 -1
- package/README.fr.md +24 -1
- package/README.hi.md +21 -1
- package/README.ja.md +22 -1
- package/README.ko.md +22 -1
- package/README.md +329 -14
- package/README.pt-BR.md +24 -1
- package/README.zh-CN.md +20 -1
- package/assets/edit-whenever.svg +75 -0
- package/dist/agent/export-manager.d.ts.map +1 -1
- package/dist/agent/export-manager.js +2 -2
- package/dist/agent/export-manager.js.map +1 -1
- package/dist/agent/session-manager.d.ts.map +1 -1
- package/dist/agent/session-manager.js.map +1 -1
- package/dist/assets/repositories/fontawesome.d.ts.map +1 -1
- package/dist/assets/repositories/fontawesome.js.map +1 -1
- package/dist/assets/repositories/openclipart.d.ts.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/design/design-systems.d.ts +126 -0
- package/dist/design/design-systems.d.ts.map +1 -0
- package/dist/design/design-systems.js +240 -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 -5
- 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 -4
- 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 +139 -0
- package/dist/ontology/vocabulary.js.map +1 -1
- package/dist/taxonomy/classifier.js +1 -1
- package/dist/taxonomy/classifier.js.map +1 -1
- package/dist/taxonomy/error-recovery.d.ts.map +1 -1
- package/dist/taxonomy/matcher.d.ts.map +1 -1
- package/dist/taxonomy/matcher.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 +1236 -45
- 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 +239 -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 +386 -22
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/minimal-descriptions.d.ts.map +1 -1
- package/dist/tools/minimal-descriptions.js +24 -4
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/toolkits.d.ts.map +1 -1
- package/dist/tools/toolkits.js +21 -0
- package/dist/tools/toolkits.js.map +1 -1
- package/dist/types/code-generator.d.ts +141 -2
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +2246 -78
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +9295 -603
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +1134 -61
- 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 +65 -1
- package/package.json +18 -9
|
@@ -4,8 +4,9 @@
|
|
|
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
|
-
import { CreateItemInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
9
|
+
import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
9
10
|
// Diagram types
|
|
10
11
|
CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
|
|
11
12
|
// Agent flow mode types
|
|
@@ -21,7 +22,7 @@ GlobeEnableInputSchema, GlobeRotateToInputSchema, GlobeSpinInputSchema, WorldTou
|
|
|
21
22
|
// Custom relation/code types
|
|
22
23
|
RegisterCustomRelationInputSchema, ExecuteCustomCodeInputSchema,
|
|
23
24
|
// Mask types
|
|
24
|
-
ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, } from './schemas.js';
|
|
25
|
+
ApplyAnimatedMaskInputSchema, ApplyCustomMaskInputSchema, RemoveMaskInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema, QueryCapabilitiesInputSchema, } from './schemas.js';
|
|
25
26
|
import { OntologyCompiler } from '../ontology/ontology-compiler.js';
|
|
26
27
|
// =============================================================================
|
|
27
28
|
// HELPER FUNCTIONS
|
|
@@ -97,6 +98,36 @@ function generateCreateItemCode(itemType, position, properties) {
|
|
|
97
98
|
if (strokeColor && !isGradient(strokeColor)) {
|
|
98
99
|
params.strokeColor = strokeColor;
|
|
99
100
|
}
|
|
101
|
+
// RENDER-TIME SURFACES DEGRADE, THEY DO NOT FAIL.
|
|
102
|
+
//
|
|
103
|
+
// `shader` and `field` are drawn per pixel by the cloud rasterizer from the
|
|
104
|
+
// scene document; the browser engine has no such item type and answers
|
|
105
|
+
// `create('shader')` with "unknown type — nothing was created". Emitting that
|
|
106
|
+
// call would hand every local user a silent hole where their backdrop was.
|
|
107
|
+
//
|
|
108
|
+
// So locally they become a plate: a rectangle carrying the same id, position,
|
|
109
|
+
// size and colour, which keeps the composition readable and the item
|
|
110
|
+
// addressable by every later call. The scene document still says `shader`, so
|
|
111
|
+
// a cloud render draws the real surface. The divergence is declared here
|
|
112
|
+
// rather than discovered in a frame.
|
|
113
|
+
if (itemType === 'shader' || itemType === 'field') {
|
|
114
|
+
const plate = {
|
|
115
|
+
x: position.x,
|
|
116
|
+
y: position.y,
|
|
117
|
+
width: params.width ?? 200,
|
|
118
|
+
height: params.height ?? 200,
|
|
119
|
+
fillColor: params.fillColor ?? params.fill ?? '#334155',
|
|
120
|
+
};
|
|
121
|
+
if (params.id)
|
|
122
|
+
plate.id = params.id;
|
|
123
|
+
if (params.opacity != null)
|
|
124
|
+
plate.opacity = params.opacity;
|
|
125
|
+
return `
|
|
126
|
+
// ${itemType} — a render-time surface. Drawn per pixel by the cloud renderer;
|
|
127
|
+
// stood in for locally by a flat plate so the layout still reads.
|
|
128
|
+
const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
|
|
129
|
+
if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }`;
|
|
130
|
+
}
|
|
100
131
|
// Build the code
|
|
101
132
|
let code = `
|
|
102
133
|
// Create ${itemType} item
|
|
@@ -309,7 +340,23 @@ app.animate(item, {
|
|
|
309
340
|
animationSpeed: ${speed}${extra}
|
|
310
341
|
});
|
|
311
342
|
|
|
312
|
-
|
|
343
|
+
// The engine records a refused type on the item rather than throwing, because
|
|
344
|
+
// animate() is on too many paths for a throw to be safe. It used to warn only
|
|
345
|
+
// to a console the production build strips, so an unknown type left the item
|
|
346
|
+
// still and every caller told it had worked. Read the record back.
|
|
347
|
+
(() => {
|
|
348
|
+
const r = item.data && item.data.animationRejected;
|
|
349
|
+
if (r) {
|
|
350
|
+
return {
|
|
351
|
+
success: false,
|
|
352
|
+
itemId: '${itemId}',
|
|
353
|
+
error: 'Unknown animationType "' + r.requested + '" — nothing will move.',
|
|
354
|
+
requested: r.requested,
|
|
355
|
+
known: r.known,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return { success: true, itemId: '${itemId}', animationType: '${animationType}' };
|
|
359
|
+
})();
|
|
313
360
|
`.trim();
|
|
314
361
|
}
|
|
315
362
|
/**
|
|
@@ -353,6 +400,36 @@ app.addAnimation('${itemId}', ${keyframesJson}, ${JSON.stringify(opts)});
|
|
|
353
400
|
/**
|
|
354
401
|
* Template for executing generators
|
|
355
402
|
*/
|
|
403
|
+
function generateImportMotionCaptureCode(bvh, mode, skeletonId, opts = {}) {
|
|
404
|
+
// Both FxTool entry points are async and return {ok, ...} rather than throwing, so the failure
|
|
405
|
+
// path is a value the model can read (unmatchedSource/unmatchedTarget tell it exactly which bone
|
|
406
|
+
// names to put in boneMap) instead of an opaque exception.
|
|
407
|
+
const bvhLiteral = JSON.stringify(bvh);
|
|
408
|
+
const optsLiteral = JSON.stringify(opts);
|
|
409
|
+
if (mode === 'retarget') {
|
|
410
|
+
return `
|
|
411
|
+
// Retarget a BVH clip onto an existing rig — proportions from the rig, motion from the capture
|
|
412
|
+
(async function() {
|
|
413
|
+
const res = await app.retargetBVH(${bvhLiteral}, ${JSON.stringify(skeletonId ?? '')}, ${optsLiteral});
|
|
414
|
+
if (!res || res.ok === false) {
|
|
415
|
+
return { success: false, error: (res && res.error) || 'retarget failed',
|
|
416
|
+
unmatchedSource: res && res.unmatchedSource, unmatchedTarget: res && res.unmatchedTarget };
|
|
417
|
+
}
|
|
418
|
+
return { success: true, skeletonId: res.skeletonId, matched: res.matched,
|
|
419
|
+
unmatchedSource: res.unmatchedSource, unmatchedTarget: res.unmatchedTarget };
|
|
420
|
+
})()`;
|
|
421
|
+
}
|
|
422
|
+
return `
|
|
423
|
+
// Import a BVH clip as a NEW skeleton
|
|
424
|
+
(async function() {
|
|
425
|
+
const res = await app.importBVH(${bvhLiteral}, ${optsLiteral});
|
|
426
|
+
if (!res || res.ok === false) {
|
|
427
|
+
return { success: false, error: (res && res.error) || 'import failed' };
|
|
428
|
+
}
|
|
429
|
+
return { success: true, skeletonId: res.skeletonId, bones: res.bones, poses: res.poses,
|
|
430
|
+
duration: res.duration };
|
|
431
|
+
})()`;
|
|
432
|
+
}
|
|
356
433
|
function generateExecuteGeneratorCode(generatorName, params, region) {
|
|
357
434
|
// FxTool signature: app.executeGenerator(name, params, options).
|
|
358
435
|
// region lives in options (added in FxTool c81781c).
|
|
@@ -427,11 +504,120 @@ const items = filtered.map(entry => ({
|
|
|
427
504
|
({ items, count: items.length });
|
|
428
505
|
`.trim();
|
|
429
506
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
507
|
+
// Where a scroll binding parks its unbind function. `bindTimelineToScroll`
|
|
508
|
+
// returns a FUNCTION, which cannot cross the tool boundary — so it lives on the
|
|
509
|
+
// page, where a later unbind_scroll call can still reach it.
|
|
510
|
+
const SCRUB_UNBIND_KEY = '__ppScrubUnbind';
|
|
511
|
+
function generatePlayTimelineCode(action, duration, loop, time, deterministic, extra) {
|
|
512
|
+
const S = (v) => JSON.stringify(v);
|
|
434
513
|
switch (action) {
|
|
514
|
+
case 'set_time_scale': {
|
|
515
|
+
// Deliberately not clamped: 0 freezes the clock without stopping, and a
|
|
516
|
+
// negative rate runs the scene backwards.
|
|
517
|
+
const rate = extra?.rate ?? 1;
|
|
518
|
+
return `
|
|
519
|
+
// pp:TimeScale — the rate the scene clock runs at.
|
|
520
|
+
(function() {
|
|
521
|
+
if (typeof app.setTimeScale !== 'function') {
|
|
522
|
+
return { success: false, error: 'app.setTimeScale unavailable — update FxTool' };
|
|
523
|
+
}
|
|
524
|
+
// Changing the rate rebases the clock so the CURRENT position is preserved;
|
|
525
|
+
// without that, doubling the rate mid-play jumps the playhead to twice its
|
|
526
|
+
// elapsed time, which reads as a glitch rather than a speed change.
|
|
527
|
+
const rate = app.setTimeScale(${S(rate)});
|
|
528
|
+
// Export drives time itself, so a scene watched at 0.5x still exports its
|
|
529
|
+
// real duration rather than a file twice as long.
|
|
530
|
+
return { success: true, action: 'set_time_scale', rate, affectsExport: false };
|
|
531
|
+
})();
|
|
532
|
+
`.trim();
|
|
533
|
+
}
|
|
534
|
+
case 'get_time_scale':
|
|
535
|
+
return `
|
|
536
|
+
(function() {
|
|
537
|
+
if (typeof app.getTimeScale !== 'function') {
|
|
538
|
+
return { success: false, error: 'app.getTimeScale unavailable — update FxTool' };
|
|
539
|
+
}
|
|
540
|
+
return { success: true, action: 'get_time_scale', rate: app.getTimeScale() };
|
|
541
|
+
})();
|
|
542
|
+
`.trim();
|
|
543
|
+
case 'get_progress':
|
|
544
|
+
return `
|
|
545
|
+
(function() {
|
|
546
|
+
if (typeof app.getProgress !== 'function') {
|
|
547
|
+
return { success: false, error: 'app.getProgress unavailable — update FxTool' };
|
|
548
|
+
}
|
|
549
|
+
return { success: true, action: 'get_progress', progress: app.getProgress() };
|
|
550
|
+
})();
|
|
551
|
+
`.trim();
|
|
552
|
+
case 'set_progress':
|
|
553
|
+
return `
|
|
554
|
+
// Seek by proportion rather than by seconds — the unit a scrubber has.
|
|
555
|
+
(function() {
|
|
556
|
+
if (typeof app.setProgress !== 'function') {
|
|
557
|
+
return { success: false, error: 'app.setProgress unavailable — update FxTool' };
|
|
558
|
+
}
|
|
559
|
+
const time = app.setProgress(${S(extra?.progress ?? 0)});
|
|
560
|
+
return { success: true, action: 'set_progress', progress: ${S(extra?.progress ?? 0)}, time };
|
|
561
|
+
})();
|
|
562
|
+
`.trim();
|
|
563
|
+
case 'list_scrub_anchors':
|
|
564
|
+
return `
|
|
565
|
+
(function() {
|
|
566
|
+
if (typeof app.listScrubAnchors !== 'function') {
|
|
567
|
+
return { success: false, error: 'app.listScrubAnchors unavailable — update FxTool' };
|
|
568
|
+
}
|
|
569
|
+
const anchors = app.listScrubAnchors();
|
|
570
|
+
return { success: true, action: 'list_scrub_anchors', count: anchors.length, anchors };
|
|
571
|
+
})();
|
|
572
|
+
`.trim();
|
|
573
|
+
case 'bind_scroll': {
|
|
574
|
+
const scroll = extra?.scroll || {};
|
|
575
|
+
return `
|
|
576
|
+
// pp:InputDrivenPlayback — drive the timeline from scroll instead of a clock.
|
|
577
|
+
(function() {
|
|
578
|
+
if (typeof app.bindTimelineToScroll !== 'function') {
|
|
579
|
+
return { success: false, error: 'app.bindTimelineToScroll unavailable — update FxTool' };
|
|
580
|
+
}
|
|
581
|
+
const cfg = ${S(scroll)};
|
|
582
|
+
// The listener holds the app instance, so rebinding without unbinding leaks
|
|
583
|
+
// the old binding AND leaves two of them scrubbing the same timeline. Always
|
|
584
|
+
// release the previous one first.
|
|
585
|
+
let replaced = false;
|
|
586
|
+
if (typeof window !== 'undefined' && typeof window.${SCRUB_UNBIND_KEY} === 'function') {
|
|
587
|
+
try { window.${SCRUB_UNBIND_KEY}(); replaced = true; } catch (e) { /* already gone */ }
|
|
588
|
+
window.${SCRUB_UNBIND_KEY} = null;
|
|
589
|
+
}
|
|
590
|
+
const el = cfg.elementId && typeof document !== 'undefined'
|
|
591
|
+
? document.getElementById(cfg.elementId)
|
|
592
|
+
: undefined;
|
|
593
|
+
if (cfg.elementId && !el) {
|
|
594
|
+
return { success: false, error: 'No element with id "' + cfg.elementId + '" — bind_scroll tracks a real DOM element' };
|
|
595
|
+
}
|
|
596
|
+
const unbind = app.bindTimelineToScroll({
|
|
597
|
+
...(el ? { element: el } : {}),
|
|
598
|
+
...(cfg.start !== undefined ? { start: cfg.start } : {}),
|
|
599
|
+
...(cfg.end !== undefined ? { end: cfg.end } : {}),
|
|
600
|
+
...(cfg.scrub !== undefined ? { scrub: cfg.scrub } : {}),
|
|
601
|
+
...(cfg.range !== undefined ? { range: cfg.range } : {}),
|
|
602
|
+
});
|
|
603
|
+
if (typeof window !== 'undefined') window.${SCRUB_UNBIND_KEY} = unbind;
|
|
604
|
+
return { success: true, action: 'bind_scroll', bound: true, replaced, config: cfg };
|
|
605
|
+
})();
|
|
606
|
+
`.trim();
|
|
607
|
+
}
|
|
608
|
+
case 'unbind_scroll':
|
|
609
|
+
return `
|
|
610
|
+
(function() {
|
|
611
|
+
// Nothing bound is an answer, not an error — an agent tidying up should not
|
|
612
|
+
// have to know whether it bound anything.
|
|
613
|
+
if (typeof window === 'undefined' || typeof window.${SCRUB_UNBIND_KEY} !== 'function') {
|
|
614
|
+
return { success: true, action: 'unbind_scroll', bound: false };
|
|
615
|
+
}
|
|
616
|
+
window.${SCRUB_UNBIND_KEY}();
|
|
617
|
+
window.${SCRUB_UNBIND_KEY} = null;
|
|
618
|
+
return { success: true, action: 'unbind_scroll', bound: true };
|
|
619
|
+
})();
|
|
620
|
+
`.trim();
|
|
435
621
|
case 'play':
|
|
436
622
|
return `
|
|
437
623
|
// Play keyframe timeline
|
|
@@ -744,6 +930,48 @@ const grid = app.createGrid({
|
|
|
744
930
|
// =============================================================================
|
|
745
931
|
// CODE GENERATOR CLASS
|
|
746
932
|
// =============================================================================
|
|
933
|
+
/**
|
|
934
|
+
* Agent PLATFORM name → the engine's canvas-preset key.
|
|
935
|
+
*
|
|
936
|
+
* AgentPlatformSchema is an export-target vocabulary ('instagram', 'youtube')
|
|
937
|
+
* that was also being handed straight to app.setCanvasSize(), which keys on
|
|
938
|
+
* 'instagram-post' and 'full-hd-1080p'. Seven of the ten names matched nothing:
|
|
939
|
+
* the engine fell through to its default and resized the artboard to 800x600
|
|
940
|
+
* while recording the preset as applied, so an agent asking for an Instagram
|
|
941
|
+
* canvas silently got neither the size nor an error. (FxTool 496d3910 now
|
|
942
|
+
* refuses an unknown key instead — better, but it warns to a console the
|
|
943
|
+
* production build strips, so the agent still learns nothing.)
|
|
944
|
+
*
|
|
945
|
+
* Mapping rather than renaming: 'instagram' is the right word for an export
|
|
946
|
+
* TARGET and a poor one for a canvas SIZE, and only the second use was wrong.
|
|
947
|
+
*/
|
|
948
|
+
const PLATFORM_TO_CANVAS_PRESET = {
|
|
949
|
+
instagram: 'instagram-post', // 1080x1080
|
|
950
|
+
'instagram-story': 'instagram-story',
|
|
951
|
+
tiktok: 'tiktok',
|
|
952
|
+
youtube: 'full-hd-1080p', // the video frame, 1920x1080
|
|
953
|
+
'youtube-thumbnail': 'youtube-thumbnail',
|
|
954
|
+
twitter: 'twitter-post',
|
|
955
|
+
linkedin: 'linkedin-post',
|
|
956
|
+
web: 'hd-720p', // 1280x720
|
|
957
|
+
'print-a4': 'a4-portrait',
|
|
958
|
+
'print-letter': 'letter-portrait',
|
|
959
|
+
};
|
|
960
|
+
/** The engine key for a platform, falling back to the value itself. */
|
|
961
|
+
export function canvasPresetFor(platform) {
|
|
962
|
+
return PLATFORM_TO_CANVAS_PRESET[platform] || platform;
|
|
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;
|
|
747
975
|
export class PinePaperCodeGenerator {
|
|
748
976
|
/**
|
|
749
977
|
* Generate code for creating an item
|
|
@@ -781,6 +1009,28 @@ export class PinePaperCodeGenerator {
|
|
|
781
1009
|
*/
|
|
782
1010
|
generateAddRelation(input) {
|
|
783
1011
|
const validated = AddRelationInputSchema.parse(input);
|
|
1012
|
+
// A PRESET decides its own relationType and params, so this is a different
|
|
1013
|
+
// call, not a defaulted one. applyRelationPreset refuses with
|
|
1014
|
+
// `{ok:false, errors:[{field, reason}]}` — a third refusal shape on this
|
|
1015
|
+
// surface, after {ok, reason} and {error} — so the errors are joined rather
|
|
1016
|
+
// than read as a single string, which would render "[object Object]".
|
|
1017
|
+
if (validated.presetId) {
|
|
1018
|
+
return `
|
|
1019
|
+
// Adopt relation preset "${validated.presetId}" on ${validated.sourceId}
|
|
1020
|
+
(async function() {
|
|
1021
|
+
if (typeof app.applyRelationPreset !== 'function') { return { success: false, error: 'app.applyRelationPreset unavailable — update FxTool' }; }
|
|
1022
|
+
const r = await app.applyRelationPreset(${JSON.stringify(validated.sourceId)}, ${JSON.stringify(validated.presetId)}, ${JSON.stringify(validated.presetValues ?? {})});
|
|
1023
|
+
if (!r || r.ok === false) {
|
|
1024
|
+
const why = (r && Array.isArray(r.errors))
|
|
1025
|
+
? r.errors.map(function(e) { return e.field + ': ' + e.reason; }).join('; ')
|
|
1026
|
+
: 'the preset did not apply';
|
|
1027
|
+
return { success: false, error: why };
|
|
1028
|
+
}
|
|
1029
|
+
// The preset's own type and resolved values come back, so the caller learns
|
|
1030
|
+
// what it actually adopted rather than only that something worked.
|
|
1031
|
+
return { success: true, presetId: ${JSON.stringify(validated.presetId)}, relationType: r.relationType, params: r.params, values: r.values };
|
|
1032
|
+
})();`.trim();
|
|
1033
|
+
}
|
|
784
1034
|
return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
|
|
785
1035
|
}
|
|
786
1036
|
/**
|
|
@@ -809,6 +1059,27 @@ export class PinePaperCodeGenerator {
|
|
|
809
1059
|
const validated = KeyframeAnimateInputSchema.parse(input);
|
|
810
1060
|
return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits);
|
|
811
1061
|
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Generate code for importing / retargeting a motion-capture clip
|
|
1064
|
+
*/
|
|
1065
|
+
generateImportMotionCapture(input) {
|
|
1066
|
+
const v = ImportMotionCaptureInputSchema.parse(input);
|
|
1067
|
+
if (v.mode === 'retarget' && !v.skeletonId) {
|
|
1068
|
+
throw new Error("pinepaper_import_motion_capture: mode='retarget' requires skeletonId (the rig to drive)");
|
|
1069
|
+
}
|
|
1070
|
+
const opts = {};
|
|
1071
|
+
if (v.fps !== undefined)
|
|
1072
|
+
opts.fps = v.fps;
|
|
1073
|
+
if (v.height !== undefined)
|
|
1074
|
+
opts.height = v.height;
|
|
1075
|
+
if (v.position !== undefined)
|
|
1076
|
+
opts.position = v.position;
|
|
1077
|
+
if (v.name !== undefined)
|
|
1078
|
+
opts.name = v.name;
|
|
1079
|
+
if (v.boneMap !== undefined)
|
|
1080
|
+
opts.boneMap = v.boneMap;
|
|
1081
|
+
return generateImportMotionCaptureCode(v.bvh, v.mode, v.skeletonId, opts);
|
|
1082
|
+
}
|
|
812
1083
|
/**
|
|
813
1084
|
* Generate code for executing a generator
|
|
814
1085
|
*/
|
|
@@ -832,8 +1103,8 @@ export class PinePaperCodeGenerator {
|
|
|
832
1103
|
/**
|
|
833
1104
|
* Generate code for timeline control
|
|
834
1105
|
*/
|
|
835
|
-
generatePlayTimeline(action, duration, loop, time, deterministic) {
|
|
836
|
-
return generatePlayTimelineCode(action, duration, loop, time, deterministic);
|
|
1106
|
+
generatePlayTimeline(action, duration, loop, time, deterministic, extra) {
|
|
1107
|
+
return generatePlayTimelineCode(action, duration, loop, time, deterministic, extra);
|
|
837
1108
|
}
|
|
838
1109
|
/**
|
|
839
1110
|
* Generate code for setting background color
|
|
@@ -997,8 +1268,29 @@ throw new Error('Either svgString or url must be provided');
|
|
|
997
1268
|
};
|
|
998
1269
|
}
|
|
999
1270
|
const ctx = app.exportCanvasOntology(${optsJson});
|
|
1271
|
+
// SAY WHEN THE ANSWER IS PARTIAL. The engine returns itemCount =
|
|
1272
|
+
// allItems.length (the TRUE total) beside items = allItems.slice(0,
|
|
1273
|
+
// maxItems), and childCount beside a children array capped at maxChildren.
|
|
1274
|
+
// So a caller receives itemCount 800 with 500 items and nothing marking the
|
|
1275
|
+
// gap — an answer indistinguishable from a complete one unless the reader
|
|
1276
|
+
// thinks to compare two fields. That is the silent-refusal shape this repo
|
|
1277
|
+
// has fixed elsewhere; the marker below is the same idiom generateHatching
|
|
1278
|
+
// uses for its 40-path cap.
|
|
1279
|
+
var _shown = Array.isArray(ctx.items) ? ctx.items.length : 0;
|
|
1280
|
+
var _total = typeof ctx.itemCount === 'number' ? ctx.itemCount : _shown;
|
|
1281
|
+
var _clipped = (Array.isArray(ctx.items) ? ctx.items : []).filter(function (i) {
|
|
1282
|
+
return i && typeof i.childCount === 'number' && Array.isArray(i.children)
|
|
1283
|
+
&& i.children.length < i.childCount;
|
|
1284
|
+
});
|
|
1000
1285
|
return {
|
|
1001
1286
|
success: true,
|
|
1287
|
+
...(_total > _shown ? { truncated:
|
|
1288
|
+
'showing ' + _shown + ' of ' + _total + ' items. Raise maxItems (up to 500) '
|
|
1289
|
+
+ 'to see more; past 500 the scene is truncated rather than paginated, so '
|
|
1290
|
+
+ 'narrow with pinepaper_get_items or read a group at a time.' } : {}),
|
|
1291
|
+
...(_clipped.length ? { childrenTruncated: _clipped.map(function (i) {
|
|
1292
|
+
return i.id + ': ' + i.children.length + ' of ' + i.childCount + ' children';
|
|
1293
|
+
}) } : {}),
|
|
1002
1294
|
canvasWidth: ctx.canvasWidth,
|
|
1003
1295
|
canvasHeight: ctx.canvasHeight,
|
|
1004
1296
|
canvasPreset: ctx.canvasPreset,
|
|
@@ -1420,7 +1712,7 @@ app.historyManager.saveState();
|
|
|
1420
1712
|
*/
|
|
1421
1713
|
generateConnect(input) {
|
|
1422
1714
|
const validated = ConnectInputSchema.parse(input);
|
|
1423
|
-
const { sourceItemId, targetItemId, routing, lineColor, lineWidth, lineStyle, headStyle, tailStyle, label, curvature, boltEnabled, boltColor, } = validated;
|
|
1715
|
+
const { id, sourceItemId, targetItemId, routing, lineColor, lineWidth, lineStyle, headStyle, tailStyle, label, curvature, boltEnabled, boltColor, } = validated;
|
|
1424
1716
|
const config = {
|
|
1425
1717
|
routing,
|
|
1426
1718
|
lineStyle,
|
|
@@ -1430,6 +1722,13 @@ app.historyManager.saveState();
|
|
|
1430
1722
|
boltEnabled,
|
|
1431
1723
|
boltColor,
|
|
1432
1724
|
};
|
|
1725
|
+
// A caller-supplied id is honoured by Connector (`config.id || <generated>`),
|
|
1726
|
+
// and it is the ONLY way a caller can address this connector afterwards:
|
|
1727
|
+
// update/remove take a connectorId, and the fallback the engine mints is
|
|
1728
|
+
// `connector_${Date.now()}_${random}` — unpredictable, and different on
|
|
1729
|
+
// every run of the same scene.
|
|
1730
|
+
if (id)
|
|
1731
|
+
config.id = id;
|
|
1433
1732
|
if (lineColor)
|
|
1434
1733
|
config.lineColor = lineColor;
|
|
1435
1734
|
if (lineWidth)
|
|
@@ -1457,8 +1756,13 @@ app.historyManager.saveState();
|
|
|
1457
1756
|
*/
|
|
1458
1757
|
generateConnectPorts(input) {
|
|
1459
1758
|
const validated = ConnectPortsInputSchema.parse(input);
|
|
1460
|
-
const { sourceItemId, sourcePort, targetItemId, targetPort, config } = validated;
|
|
1461
|
-
|
|
1759
|
+
const { id, sourceItemId, sourcePort, targetItemId, targetPort, config } = validated;
|
|
1760
|
+
// `id` sits at the TOP level of the tool's input but belongs inside the
|
|
1761
|
+
// engine's config object, which is where Connector reads it from. Merged
|
|
1762
|
+
// here rather than asking callers to nest it, since every other style
|
|
1763
|
+
// option they pass is nested and the id is not a style.
|
|
1764
|
+
const merged = id ? { ...(config ?? {}), id } : config;
|
|
1765
|
+
const configStr = merged ? JSON.stringify(merged, null, 2) : '{}';
|
|
1462
1766
|
return `
|
|
1463
1767
|
// Connect ports: ${sourceItemId}:${sourcePort} -> ${targetItemId}:${targetPort}
|
|
1464
1768
|
const sourceItem = app.getItemById('${sourceItemId}');
|
|
@@ -1659,8 +1963,8 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
1659
1963
|
}
|
|
1660
1964
|
if (canvasPreset) {
|
|
1661
1965
|
code += `
|
|
1662
|
-
// Set canvas size to preset
|
|
1663
|
-
app.setCanvasSize('${canvasPreset}');
|
|
1966
|
+
// Set canvas size to preset (platform name → engine preset key)
|
|
1967
|
+
app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
|
|
1664
1968
|
// Allow canvas resize to take effect
|
|
1665
1969
|
await new Promise(r => setTimeout(r, 50));
|
|
1666
1970
|
`;
|
|
@@ -1866,8 +2170,8 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
1866
2170
|
}
|
|
1867
2171
|
if (canvasPreset) {
|
|
1868
2172
|
code += `
|
|
1869
|
-
// Set canvas to preset
|
|
1870
|
-
app.setCanvasSize('${canvasPreset}');
|
|
2173
|
+
// Set canvas to preset (platform name → engine preset key)
|
|
2174
|
+
app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
|
|
1871
2175
|
// Allow canvas resize to take effect
|
|
1872
2176
|
await new Promise(r => setTimeout(r, 50));
|
|
1873
2177
|
`;
|
|
@@ -2115,6 +2419,10 @@ const item = app.getItemById(targetId);
|
|
|
2115
2419
|
if (!item) throw new Error('Item not found: ' + targetId);
|
|
2116
2420
|
if (!app.applyAnimatedMask) throw new Error('Mask system not available');
|
|
2117
2421
|
const maskedGroup = app.applyAnimatedMask(item, '${preset}', ${maskOpts});
|
|
2422
|
+
const maskRej = maskedGroup && maskedGroup.data && maskedGroup.data.maskAnimationRejected;
|
|
2423
|
+
if (maskRej) {
|
|
2424
|
+
return { success: false, itemId: targetId, error: 'Masked, but animation preset "' + maskRej.requested + '" is unknown — it will NOT animate.', requested: maskRej.requested, known: maskRej.known };
|
|
2425
|
+
}
|
|
2118
2426
|
return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
|
|
2119
2427
|
`;
|
|
2120
2428
|
}
|
|
@@ -2161,6 +2469,45 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2161
2469
|
/**
|
|
2162
2470
|
* Generate code for smart export
|
|
2163
2471
|
*/
|
|
2472
|
+
/**
|
|
2473
|
+
* Read one slice of a held export, base64-encoded.
|
|
2474
|
+
*
|
|
2475
|
+
* The handler calls this in a loop and appends each chunk to the file it was
|
|
2476
|
+
* going to write anyway, so no more than one chunk is ever in memory on
|
|
2477
|
+
* either side of the bridge. Every refusal the engine can give is passed
|
|
2478
|
+
* through untouched — a bad range names the real size, and an id that was
|
|
2479
|
+
* EVICTED to make room says so rather than reporting not-found, because
|
|
2480
|
+
* those are different bugs from the caller's side.
|
|
2481
|
+
*/
|
|
2482
|
+
generateReadExportChunk(exportId, offset, length) {
|
|
2483
|
+
const range = { offset };
|
|
2484
|
+
if (typeof length === 'number')
|
|
2485
|
+
range.length = length;
|
|
2486
|
+
return `
|
|
2487
|
+
// Read export chunk at ${offset}
|
|
2488
|
+
(async function() {
|
|
2489
|
+
if (!app.exportEngine || typeof app.exportEngine.readExport !== 'function') {
|
|
2490
|
+
return { ok: false, reason: 'app.exportEngine.readExport unavailable — update FxTool' };
|
|
2491
|
+
}
|
|
2492
|
+
return await app.exportEngine.readExport(${JSON.stringify(exportId)}, ${JSON.stringify(range)});
|
|
2493
|
+
})();`.trim();
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Drop a held export once its bytes are safely on disk here.
|
|
2497
|
+
*
|
|
2498
|
+
* Called only after the whole file has been written. A release before that
|
|
2499
|
+
* would be unrecoverable: the store is the only copy.
|
|
2500
|
+
*/
|
|
2501
|
+
generateReleaseExport(exportId) {
|
|
2502
|
+
return `
|
|
2503
|
+
// Release held export
|
|
2504
|
+
(async function() {
|
|
2505
|
+
if (!app.exportEngine || typeof app.exportEngine.releaseExport !== 'function') {
|
|
2506
|
+
return { ok: false, reason: 'app.exportEngine.releaseExport unavailable — update FxTool' };
|
|
2507
|
+
}
|
|
2508
|
+
return await app.exportEngine.releaseExport(${JSON.stringify(exportId)});
|
|
2509
|
+
})();`.trim();
|
|
2510
|
+
}
|
|
2164
2511
|
generateAgentExport(input) {
|
|
2165
2512
|
const validated = AgentExportInputSchema.parse(input);
|
|
2166
2513
|
const { platform, format, quality, framing, duration, estimateOnly } = validated;
|
|
@@ -2188,6 +2535,76 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2188
2535
|
const preset = platformPresets[platform] || platformPresets['web'];
|
|
2189
2536
|
// Resolve "auto" format to the platform's recommended format
|
|
2190
2537
|
const exportFormat = (!format || format === 'auto') ? preset.staticFormat : format;
|
|
2538
|
+
// AUDIO-ONLY EXPORT, off the visual path entirely.
|
|
2539
|
+
//
|
|
2540
|
+
// It takes the OFFLINE renderer, not exportEngine.exportAudio. The latter
|
|
2541
|
+
// is async, needs the videoExporter's Web Audio path, hands back a Blob
|
|
2542
|
+
// that cannot cross page.evaluate, and downloads unless told not to.
|
|
2543
|
+
// renderSoundtrackWav is synchronous, returns bytes, and needs no audio
|
|
2544
|
+
// context — the engine commit that added it is titled "the soundtrack
|
|
2545
|
+
// without Web Audio", and this is the context it was written for.
|
|
2546
|
+
//
|
|
2547
|
+
// No platform preset resolves to wav, so reaching here means it was asked
|
|
2548
|
+
// for by name. Dimensions, framing, fps and bitrate are all absent rather
|
|
2549
|
+
// than passed and ignored.
|
|
2550
|
+
// @engine-methods renderSoundtrackWav exportEngine.sceneHasAudio
|
|
2551
|
+
if (exportFormat === 'wav') {
|
|
2552
|
+
const sr = validated.sampleRate ?? 48000;
|
|
2553
|
+
const bd = validated.bitDepth ?? 16;
|
|
2554
|
+
return `
|
|
2555
|
+
// Export: the soundtrack on its own, rendered offline
|
|
2556
|
+
(function() {
|
|
2557
|
+
if (typeof app.renderSoundtrackWav !== 'function') {
|
|
2558
|
+
return { success: false, format: 'wav', error: 'app.renderSoundtrackWav unavailable — update FxTool. This build cannot export audio on its own; mp4/webm still mux the soundtrack into the video.' };
|
|
2559
|
+
}
|
|
2560
|
+
// EXACT, not estimated. Uncompressed PCM is header + rate x bytes x channels
|
|
2561
|
+
// x seconds, so a model is not needed and would only be less true. The mix
|
|
2562
|
+
// is mono — the engine encodes a single channel.
|
|
2563
|
+
var exact = 44 + Math.round(${sr} * (${bd} / 8) * 1 * ${videoDuration});
|
|
2564
|
+
if (${estimateOnly ? 'true' : 'false'}) {
|
|
2565
|
+
return { success: true, estimateOnly: true, format: 'wav', bytes: exact,
|
|
2566
|
+
expected: exact, confidence: 'exact',
|
|
2567
|
+
note: 'uncompressed PCM: ' + ${videoDuration} + 's at ' + ${sr} + ' Hz, ' + ${bd} + '-bit mono' };
|
|
2568
|
+
}
|
|
2569
|
+
var r = app.renderSoundtrackWav({ duration: ${videoDuration}, sampleRate: ${sr}, bitDepth: ${bd} });
|
|
2570
|
+
if (!r || !r.wav) {
|
|
2571
|
+
// WHY IT IS EMPTY, NOT JUST THAT IT IS. The offline renderer bakes
|
|
2572
|
+
// SYNTHESIZED sounds only, while the engine's sceneHasAudio() counts
|
|
2573
|
+
// uploaded audio files too. A scene carrying an uploaded track would
|
|
2574
|
+
// otherwise be told it has no audio, which is false and sends the caller
|
|
2575
|
+
// to add a sound they already have.
|
|
2576
|
+
var anyAudio = null;
|
|
2577
|
+
try {
|
|
2578
|
+
if (app.exportEngine && typeof app.exportEngine.sceneHasAudio === 'function') {
|
|
2579
|
+
anyAudio = app.exportEngine.sceneHasAudio();
|
|
2580
|
+
}
|
|
2581
|
+
} catch (_) { /* predicate absent — fall back to the generic answer */ }
|
|
2582
|
+
return {
|
|
2583
|
+
success: false, format: 'wav',
|
|
2584
|
+
error: anyAudio === true
|
|
2585
|
+
? '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.'
|
|
2586
|
+
: '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".',
|
|
2587
|
+
sceneHasAudio: anyAudio,
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
var bytes = r.wav;
|
|
2591
|
+
// Chunked: String.fromCharCode.apply over a multi-megabyte array blows the
|
|
2592
|
+
// stack, and a ten-minute soundtrack is tens of megabytes.
|
|
2593
|
+
var bin = '';
|
|
2594
|
+
for (var i = 0; i < bytes.length; i += 8192) {
|
|
2595
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
2596
|
+
}
|
|
2597
|
+
return {
|
|
2598
|
+
success: true, format: 'wav', platform: '${platform}',
|
|
2599
|
+
data: 'data:audio/wav;base64,' + btoa(bin),
|
|
2600
|
+
size: bytes.length,
|
|
2601
|
+
duration: r.duration, sampleRate: r.sampleRate, sounds: r.sounds, placed: r.placed,
|
|
2602
|
+
// A mix that dropped sounds is a valid file quietly missing tracks.
|
|
2603
|
+
...(r.dropped > 0 ? { dropped: r.dropped, incomplete:
|
|
2604
|
+
'mixed ' + r.placed + ' sound(s); ' + r.dropped + ' could not be rendered and are absent from this file' } : {}),
|
|
2605
|
+
};
|
|
2606
|
+
})();`.trim();
|
|
2607
|
+
}
|
|
2191
2608
|
return `
|
|
2192
2609
|
// Smart export for ${platform}
|
|
2193
2610
|
(async function() {
|
|
@@ -2227,7 +2644,12 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2227
2644
|
|
|
2228
2645
|
// Resolve camera-view framing dims when requested. Reads the first
|
|
2229
2646
|
// camera_animates keyframe's zoom and divides the canvas dims by it
|
|
2230
|
-
// (mirrors
|
|
2647
|
+
// (mirrors the camera-view framing block in FxTool's
|
|
2648
|
+
// ExportEngine.showVideoExportOptions). Cited by METHOD, not by line: the
|
|
2649
|
+
// previous citation named a line range that had rotted to point at
|
|
2650
|
+
// DPI/canvas-size code in a file nobody here edits. A cross-file line range
|
|
2651
|
+
// cannot be kept accurate — it breaks on any edit above it — so this repo
|
|
2652
|
+
// does not use them. Camera animation still
|
|
2231
2653
|
// drives motion during export — framing only fixes the output frame.
|
|
2232
2654
|
let cameraDims = null;
|
|
2233
2655
|
if (framing === 'camera') {
|
|
@@ -2312,32 +2734,110 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
2312
2734
|
r.onloadend = () => resolve(r.result);
|
|
2313
2735
|
r.readAsDataURL(b);
|
|
2314
2736
|
});
|
|
2737
|
+
|
|
2738
|
+
// THE EXPORT STORE. A long export cannot come back as one base64
|
|
2739
|
+
// string: base64 of a gigabyte is larger than the gigabyte, and it
|
|
2740
|
+
// crosses the browser→server bridge as a single page.evaluate return
|
|
2741
|
+
// value. exportToStore() streams the encode straight to the studio's
|
|
2742
|
+
// origin-private store and hands back an id, so the bytes stay on disk
|
|
2743
|
+
// and the handler pages them into the file it was going to write
|
|
2744
|
+
// anyway. mp4/webm ALWAYS land in a file on the server side, so there
|
|
2745
|
+
// is no size at which building a data URL for them is worth doing —
|
|
2746
|
+
// the id goes back at every size and nothing is base64'd in the page.
|
|
2747
|
+
// gif is refused by name by the store (it has its own encoder and
|
|
2748
|
+
// never streams), so gif keeps the buffered path below.
|
|
2749
|
+
const store = (app.exportEngine
|
|
2750
|
+
&& typeof app.exportEngine.exportToStore === 'function'
|
|
2751
|
+
&& typeof app.exportEngine.readExport === 'function')
|
|
2752
|
+
? app.exportEngine : null;
|
|
2753
|
+
|
|
2754
|
+
if (store && format !== 'gif') {
|
|
2755
|
+
const stored = await store.exportToStore({
|
|
2756
|
+
format,
|
|
2757
|
+
duration: baseVideoSettings.duration,
|
|
2758
|
+
fps: settings.fps,
|
|
2759
|
+
// A NUMBER, not the quality tier's name. _calculateBitrate
|
|
2760
|
+
// multiplies by it, and a non-finite value used to survive its
|
|
2761
|
+
// Math.max(100000, NaN) "floor" all the way to
|
|
2762
|
+
// VideoEncoder.configure.
|
|
2763
|
+
quality: settings.compression,
|
|
2764
|
+
width: cameraDims ? cameraDims.width : dimensions.width,
|
|
2765
|
+
height: cameraDims ? cameraDims.height : dimensions.height,
|
|
2766
|
+
});
|
|
2767
|
+
if (!stored || stored.ok === false) {
|
|
2768
|
+
result = { success: false, platform, format, error: (stored && stored.reason) || 'the export store refused the export without saying why' };
|
|
2769
|
+
} else {
|
|
2770
|
+
let chunkBytes = 4 * 1024 * 1024;
|
|
2771
|
+
try {
|
|
2772
|
+
const declared = app.exportEngine.constructor && app.exportEngine.constructor.EXPORT_CHUNK_BYTES;
|
|
2773
|
+
if (typeof declared === 'number' && declared > 0) chunkBytes = declared;
|
|
2774
|
+
} catch (_) { /* keep the default */ }
|
|
2775
|
+
result = {
|
|
2776
|
+
success: true, platform, framing,
|
|
2777
|
+
// Trust the container the store reports over the one asked for:
|
|
2778
|
+
// mp4 falls back to WebM without WebCodecs, and WebM bytes named
|
|
2779
|
+
// .mp4 fail in container-validating players.
|
|
2780
|
+
format: stored.format || format,
|
|
2781
|
+
retained: true,
|
|
2782
|
+
exportId: stored.exportId,
|
|
2783
|
+
mimeType: stored.mimeType || videoMimeType,
|
|
2784
|
+
size: stored.size,
|
|
2785
|
+
chunkBytes: chunkBytes,
|
|
2786
|
+
dimensions: cameraDims || dimensions,
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
break;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
// NO EXPORT STORE ON THIS STUDIO, or gif. Both remaining failures are
|
|
2793
|
+
// named rather than left to blow up: a streamed export hands back a
|
|
2794
|
+
// marker instead of a Blob and FileReader throws on it, and an export
|
|
2795
|
+
// past the inline ceiling used to die somewhere in the base64 rather
|
|
2796
|
+
// than say so. The ceiling sits above what this path could already do
|
|
2797
|
+
// (60 s at 8 Mbps is roughly 60 MB) so nothing that works today starts
|
|
2798
|
+
// refusing.
|
|
2799
|
+
const INLINE_MAX_BYTES = ${INLINE_MAX_BYTES};
|
|
2800
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
2801
|
+
const deliver = async (blob) => {
|
|
2802
|
+
if (!blob || typeof blob.size !== 'number' || typeof blob.slice !== 'function') {
|
|
2803
|
+
return {
|
|
2804
|
+
success: false, platform, format,
|
|
2805
|
+
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',
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
if (blob.size > INLINE_MAX_BYTES) {
|
|
2809
|
+
return {
|
|
2810
|
+
success: false, platform, format, size: blob.size,
|
|
2811
|
+
error: 'export is ' + mb(blob.size) + ' MB, over the ' + mb(INLINE_MAX_BYTES)
|
|
2812
|
+
+ ' MB inline ceiling, and this studio has no app.exportEngine.exportToStore to page it out of — update FxTool, or lower duration/quality',
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
return { success: true, platform, format, framing, data: await blobToDataUrl(blob), mimeType: videoMimeType, size: blob.size };
|
|
2816
|
+
};
|
|
2817
|
+
|
|
2315
2818
|
// Camera framing requires going direct to videoExporter so width/height
|
|
2316
2819
|
// pass through — _quickExportVideo strips dim fields.
|
|
2317
2820
|
if (cameraDims && app.exportEngine && app.exportEngine.videoExporter) {
|
|
2318
2821
|
const blob = await app.exportEngine.videoExporter.export({ ...baseVideoSettings, width: cameraDims.width, height: cameraDims.height });
|
|
2319
|
-
|
|
2320
|
-
result
|
|
2822
|
+
result = await deliver(blob);
|
|
2823
|
+
if (result.success) result.dimensions = cameraDims;
|
|
2321
2824
|
} else if ((format === 'mp4' || format === 'webm') && app.exportEngine && app.exportEngine.videoExporter) {
|
|
2322
2825
|
// Bypass _quickExportVideo for mp4/webm — it hardcodes
|
|
2323
2826
|
// quality:undefined for these formats and produces a NaN bitrate.
|
|
2324
2827
|
const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
|
|
2325
|
-
|
|
2326
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: blob.size };
|
|
2828
|
+
result = await deliver(blob);
|
|
2327
2829
|
} else if (app.exportEngine && app.exportEngine._quickExportVideo) {
|
|
2328
2830
|
// GIF path (working): _quickExportVideo forwards gifQuality to
|
|
2329
2831
|
// gif.js. Also serves as the fallback if videoExporter is absent.
|
|
2330
2832
|
const videoResult = await app.exportEngine._quickExportVideo(format, baseVideoSettings, false);
|
|
2331
2833
|
if (videoResult && videoResult.blob) {
|
|
2332
|
-
|
|
2333
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: videoResult.blob.size };
|
|
2834
|
+
result = await deliver(videoResult.blob);
|
|
2334
2835
|
} else {
|
|
2335
2836
|
result = { success: false, error: format.toUpperCase() + ' export returned no data' };
|
|
2336
2837
|
}
|
|
2337
2838
|
} else if (app.exportEngine && app.exportEngine.videoExporter) {
|
|
2338
2839
|
const blob = await app.exportEngine.videoExporter.export(baseVideoSettings);
|
|
2339
|
-
|
|
2340
|
-
result = { success: true, platform, format, framing, data: dataUrl, mimeType: videoMimeType, size: blob.size };
|
|
2840
|
+
result = await deliver(blob);
|
|
2341
2841
|
} else {
|
|
2342
2842
|
result = { success: false, error: format.toUpperCase() + ' export not available' };
|
|
2343
2843
|
}
|
|
@@ -3618,6 +4118,18 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
3618
4118
|
} else {
|
|
3619
4119
|
maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
|
|
3620
4120
|
}
|
|
4121
|
+
// A mask with an unknown ANIMATION preset still masks — the item just never
|
|
4122
|
+
// animates — so !!maskedGroup is true either way. The engine records which.
|
|
4123
|
+
const rej = maskedGroup && maskedGroup.data && maskedGroup.data.maskAnimationRejected;
|
|
4124
|
+
if (rej) {
|
|
4125
|
+
return {
|
|
4126
|
+
success: false,
|
|
4127
|
+
itemId: '${validated.itemId}',
|
|
4128
|
+
error: 'Masked, but the animation preset "' + rej.requested + '" is unknown — it will NOT animate.',
|
|
4129
|
+
requested: rej.requested,
|
|
4130
|
+
known: rej.known,
|
|
4131
|
+
};
|
|
4132
|
+
}
|
|
3621
4133
|
return { success: !!maskedGroup, itemId: '${validated.itemId}', preset: '${preset}' };
|
|
3622
4134
|
} catch (error) {
|
|
3623
4135
|
return { success: false, error: error.message };
|
|
@@ -4052,6 +4564,238 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
4052
4564
|
return `(function() { return { error: 'Unknown scene_playback action: ${input.action}' }; })();`;
|
|
4053
4565
|
}
|
|
4054
4566
|
}
|
|
4567
|
+
generateSceneGraph(input) {
|
|
4568
|
+
const validated = SceneGraphInputSchema.parse(input);
|
|
4569
|
+
const S = (v) => JSON.stringify(v);
|
|
4570
|
+
if (validated.action === 'validate') {
|
|
4571
|
+
return `
|
|
4572
|
+
// Check a scene graph WITHOUT drawing it — the same check createSceneGraph runs,
|
|
4573
|
+
// so an author can fix the graph before committing to a canvas full of cards.
|
|
4574
|
+
(function() {
|
|
4575
|
+
if (typeof app.validateSceneGraph !== 'function') {
|
|
4576
|
+
return { success: false, error: 'app.validateSceneGraph unavailable — update FxTool' };
|
|
4577
|
+
}
|
|
4578
|
+
const r = app.validateSceneGraph(${S(validated.graph)});
|
|
4579
|
+
return {
|
|
4580
|
+
success: true,
|
|
4581
|
+
action: 'validate',
|
|
4582
|
+
ok: r ? r.ok : false,
|
|
4583
|
+
errors: (r && r.errors) || [],
|
|
4584
|
+
// Cycles are legitimate (a retry loop) but reported: an accidental one
|
|
4585
|
+
// looks exactly like a game that never ends.
|
|
4586
|
+
warnings: (r && r.warnings) || [],
|
|
4587
|
+
reachable: (r && r.reachable) || [],
|
|
4588
|
+
cycles: (r && r.cycles) || [],
|
|
4589
|
+
};
|
|
4590
|
+
})();
|
|
4591
|
+
`.trim();
|
|
4592
|
+
}
|
|
4593
|
+
return `
|
|
4594
|
+
// Create interactive scene graph (story / quiz cards)
|
|
4595
|
+
(async function() {
|
|
4596
|
+
if (typeof app.createSceneGraph !== 'function') {
|
|
4597
|
+
return { success: false, error: 'app.createSceneGraph unavailable — update FxTool' };
|
|
4598
|
+
}
|
|
4599
|
+
const result = await app.createSceneGraph(${S(validated.graph)}, ${S(validated.opts || {})});
|
|
4600
|
+
if (!result) return { success: false, error: 'No result returned' };
|
|
4601
|
+
return {
|
|
4602
|
+
success: result.ok !== false,
|
|
4603
|
+
ok: result.ok,
|
|
4604
|
+
errors: result.errors || [],
|
|
4605
|
+
warnings: result.warnings || [],
|
|
4606
|
+
// The engine reports these and this tool used to drop them. \`failed\` is the
|
|
4607
|
+
// one that matters: a graph can compile, render, and still leave N click
|
|
4608
|
+
// relations unwired — it looks built and is inert, and nothing else in the
|
|
4609
|
+
// result says so. A refused plan returns early without them, hence the
|
|
4610
|
+
// defaults.
|
|
4611
|
+
wired: result.wired,
|
|
4612
|
+
failed: result.failed,
|
|
4613
|
+
cycles: result.cycles || [],
|
|
4614
|
+
entranced: result.entranced,
|
|
4615
|
+
start: result.start,
|
|
4616
|
+
ids: result.ids || {},
|
|
4617
|
+
};
|
|
4618
|
+
})();
|
|
4619
|
+
`.trim();
|
|
4620
|
+
}
|
|
4621
|
+
// ===========================================================================
|
|
4622
|
+
// RELATIVE TIMING, STAGGERS, FLIP
|
|
4623
|
+
// ===========================================================================
|
|
4624
|
+
generateSequence(input) {
|
|
4625
|
+
const validated = SequenceInputSchema.parse(input);
|
|
4626
|
+
const S = (v) => JSON.stringify(v);
|
|
4627
|
+
if (validated.action === 'list_forms') {
|
|
4628
|
+
return `
|
|
4629
|
+
(function() {
|
|
4630
|
+
if (typeof app.listPositionForms !== 'function') {
|
|
4631
|
+
return { success: false, error: 'app.listPositionForms unavailable — update FxTool' };
|
|
4632
|
+
}
|
|
4633
|
+
const forms = app.listPositionForms();
|
|
4634
|
+
return { success: true, action: 'list_forms', count: forms.length, forms };
|
|
4635
|
+
})();
|
|
4636
|
+
`.trim();
|
|
4637
|
+
}
|
|
4638
|
+
if (validated.action === 'resolve') {
|
|
4639
|
+
return `
|
|
4640
|
+
// Resolve ONE relative position to absolute seconds.
|
|
4641
|
+
(function() {
|
|
4642
|
+
if (typeof app.resolveTimelinePosition !== 'function') {
|
|
4643
|
+
return { success: false, error: 'app.resolveTimelinePosition unavailable — update FxTool' };
|
|
4644
|
+
}
|
|
4645
|
+
const ctx = { ...${S(validated.context || {})}, labels: ${S(validated.labels || {})} };
|
|
4646
|
+
const seconds = app.resolveTimelinePosition(${S(validated.position ?? null)}, ctx);
|
|
4647
|
+
return { success: true, action: 'resolve', position: ${S(validated.position ?? null)}, seconds, context: ctx };
|
|
4648
|
+
})();
|
|
4649
|
+
`.trim();
|
|
4650
|
+
}
|
|
4651
|
+
return `
|
|
4652
|
+
// Place a run of clips in order, each resolved against the ones before it.
|
|
4653
|
+
(function() {
|
|
4654
|
+
if (typeof app.placeSequence !== 'function') {
|
|
4655
|
+
return { success: false, error: 'app.placeSequence unavailable — update FxTool' };
|
|
4656
|
+
}
|
|
4657
|
+
const clips = ${S(validated.clips || [])};
|
|
4658
|
+
const result = app.placeSequence(clips, {
|
|
4659
|
+
startAt: ${S(validated.startAt ?? 0)},
|
|
4660
|
+
labels: ${S(validated.labels || {})},
|
|
4661
|
+
});
|
|
4662
|
+
// Echo the caller's own clip ids back onto the placements — placeSequence
|
|
4663
|
+
// answers positionally, and an agent that named its clips should get the
|
|
4664
|
+
// names back rather than having to re-zip two arrays by index.
|
|
4665
|
+
const placed = (result.placed || []).map((p, i) => (
|
|
4666
|
+
clips[i] && clips[i].id ? { id: clips[i].id, ...p } : p
|
|
4667
|
+
));
|
|
4668
|
+
return {
|
|
4669
|
+
success: true,
|
|
4670
|
+
action: 'place',
|
|
4671
|
+
placed,
|
|
4672
|
+
labels: result.labels,
|
|
4673
|
+
// The timeline only ever grows: a clip placed early with "-=" must not pull
|
|
4674
|
+
// the end back and silently truncate everything after it.
|
|
4675
|
+
duration: result.duration,
|
|
4676
|
+
};
|
|
4677
|
+
})();
|
|
4678
|
+
`.trim();
|
|
4679
|
+
}
|
|
4680
|
+
generateStagger(input) {
|
|
4681
|
+
const validated = StaggerInputSchema.parse(input);
|
|
4682
|
+
const S = (v) => JSON.stringify(v);
|
|
4683
|
+
if (validated.action === 'list_origins') {
|
|
4684
|
+
return `
|
|
4685
|
+
(function() {
|
|
4686
|
+
if (typeof app.listStaggerOrigins !== 'function') {
|
|
4687
|
+
return { success: false, error: 'app.listStaggerOrigins unavailable — update FxTool' };
|
|
4688
|
+
}
|
|
4689
|
+
const origins = app.listStaggerOrigins();
|
|
4690
|
+
return { success: true, action: 'list_origins', count: origins.length, origins };
|
|
4691
|
+
})();
|
|
4692
|
+
`.trim();
|
|
4693
|
+
}
|
|
4694
|
+
if (validated.action === 'preview') {
|
|
4695
|
+
return `
|
|
4696
|
+
// Delays for a count, without touching the canvas — plan before committing.
|
|
4697
|
+
(function() {
|
|
4698
|
+
if (typeof app.staggerDelays !== 'function') {
|
|
4699
|
+
return { success: false, error: 'app.staggerDelays unavailable — update FxTool' };
|
|
4700
|
+
}
|
|
4701
|
+
const delays = app.staggerDelays(${S(validated.count ?? 0)}, ${S(validated.opts || {})});
|
|
4702
|
+
return {
|
|
4703
|
+
success: true,
|
|
4704
|
+
action: 'preview',
|
|
4705
|
+
count: ${S(validated.count ?? 0)},
|
|
4706
|
+
delays,
|
|
4707
|
+
span: delays.length ? Math.max(...delays) : 0,
|
|
4708
|
+
};
|
|
4709
|
+
})();
|
|
4710
|
+
`.trim();
|
|
4711
|
+
}
|
|
4712
|
+
return `
|
|
4713
|
+
// Apply a stagger to real items, in the order given.
|
|
4714
|
+
(function() {
|
|
4715
|
+
if (typeof app.staggerItems !== 'function') {
|
|
4716
|
+
return { success: false, error: 'app.staggerItems unavailable — update FxTool' };
|
|
4717
|
+
}
|
|
4718
|
+
const ids = ${S(validated.itemIds || [])};
|
|
4719
|
+
if (!ids.length) {
|
|
4720
|
+
return { success: false, error: 'apply needs itemIds, in the order the stagger should follow (row-major for a grid)' };
|
|
4721
|
+
}
|
|
4722
|
+
const r = app.staggerItems(ids, ${S(validated.opts || {})});
|
|
4723
|
+
return {
|
|
4724
|
+
success: r && r.ok === true,
|
|
4725
|
+
action: 'apply',
|
|
4726
|
+
// 'applied' can be lower than the ids given — an id that resolves to
|
|
4727
|
+
// nothing is skipped, and a stagger that silently covered fewer items than
|
|
4728
|
+
// asked for is the failure worth seeing.
|
|
4729
|
+
requested: ids.length,
|
|
4730
|
+
applied: r ? r.applied : 0,
|
|
4731
|
+
delays: r ? r.delays : [],
|
|
4732
|
+
span: r ? r.span : 0,
|
|
4733
|
+
};
|
|
4734
|
+
})();
|
|
4735
|
+
`.trim();
|
|
4736
|
+
}
|
|
4737
|
+
generateFlip(input) {
|
|
4738
|
+
const validated = FlipInputSchema.parse(input);
|
|
4739
|
+
const S = (v) => JSON.stringify(v);
|
|
4740
|
+
if (validated.action === 'record') {
|
|
4741
|
+
return `
|
|
4742
|
+
// pp:Flip step one — record where things are NOW, before the change.
|
|
4743
|
+
//
|
|
4744
|
+
// The record stays ON THE PAGE rather than travelling back through the tool
|
|
4745
|
+
// result: it is one snapshot per item of raw geometry, useless to read and
|
|
4746
|
+
// expensive to carry, and \`apply\` is the only thing that ever wants it.
|
|
4747
|
+
(function() {
|
|
4748
|
+
if (typeof app.recordFlipState !== 'function') {
|
|
4749
|
+
return { success: false, error: 'app.recordFlipState unavailable — update FxTool' };
|
|
4750
|
+
}
|
|
4751
|
+
const ids = ${S(validated.itemIds || null)};
|
|
4752
|
+
const record = ids && ids.length ? app.recordFlipState(ids) : app.recordFlipState();
|
|
4753
|
+
if (typeof window !== 'undefined') window.__ppFlipRecord = record;
|
|
4754
|
+
return {
|
|
4755
|
+
success: true,
|
|
4756
|
+
action: 'record',
|
|
4757
|
+
recorded: (record.snapshots || []).length,
|
|
4758
|
+
takenAt: record.takenAt,
|
|
4759
|
+
ids: (record.snapshots || []).map(s => s.id),
|
|
4760
|
+
};
|
|
4761
|
+
})();
|
|
4762
|
+
`.trim();
|
|
4763
|
+
}
|
|
4764
|
+
const opts = {};
|
|
4765
|
+
if (validated.duration !== undefined)
|
|
4766
|
+
opts.duration = validated.duration;
|
|
4767
|
+
if (validated.easing !== undefined)
|
|
4768
|
+
opts.easing = validated.easing;
|
|
4769
|
+
if (validated.enter !== undefined)
|
|
4770
|
+
opts.enter = validated.enter;
|
|
4771
|
+
if (validated.stagger !== undefined)
|
|
4772
|
+
opts.stagger = validated.stagger;
|
|
4773
|
+
return `
|
|
4774
|
+
// pp:Flip step two — animate from the recorded state to wherever things are now.
|
|
4775
|
+
// Writes ordinary keyframes, so the transition scrubs, exports to SMIL and
|
|
4776
|
+
// Lottie, and survives a reload.
|
|
4777
|
+
(function() {
|
|
4778
|
+
if (typeof app.flipItems !== 'function') {
|
|
4779
|
+
return { success: false, error: 'app.flipItems unavailable — update FxTool' };
|
|
4780
|
+
}
|
|
4781
|
+
const record = typeof window !== 'undefined' ? window.__ppFlipRecord : null;
|
|
4782
|
+
if (!record || !(record.snapshots || []).length) {
|
|
4783
|
+
return { success: false, error: 'Nothing was recorded — run pinepaper_flip { action: "record" } BEFORE the change, then rearrange, then apply' };
|
|
4784
|
+
}
|
|
4785
|
+
const r = app.flipItems(record, ${S(opts)});
|
|
4786
|
+
return {
|
|
4787
|
+
success: r && r.ok === true,
|
|
4788
|
+
action: 'apply',
|
|
4789
|
+
moved: r ? r.moved : 0,
|
|
4790
|
+
entered: r ? r.entered : [],
|
|
4791
|
+
// Items that LEFT during the change cannot be animated out — they are gone
|
|
4792
|
+
// already. Remove them after the flip, not before.
|
|
4793
|
+
left: r ? r.left : [],
|
|
4794
|
+
duration: r ? r.duration : 0,
|
|
4795
|
+
};
|
|
4796
|
+
})();
|
|
4797
|
+
`.trim();
|
|
4798
|
+
}
|
|
4055
4799
|
// ===========================================================================
|
|
4056
4800
|
// SELECTION, TRANSFORM & HISTORY
|
|
4057
4801
|
// ===========================================================================
|
|
@@ -4261,6 +5005,14 @@ ${mask ? ` app.imageTools.applyMask(raster, '${mask}');\n` : ''} // The RE
|
|
|
4261
5005
|
...(input.assets ? { assets: input.assets } : {}),
|
|
4262
5006
|
...(input.audio ? { audio: input.audio } : {}),
|
|
4263
5007
|
...(input.grid ? { grid: true } : {}),
|
|
5008
|
+
// The design axes. A register resolves the craft (gutter, margin,
|
|
5009
|
+
// hue budget, type scale); a medium says what makes the marks and
|
|
5010
|
+
// is REFUSED when a composition cannot be rendered in it.
|
|
5011
|
+
...(input.register ? { register: input.register } : {}),
|
|
5012
|
+
...(input.level !== undefined ? { level: input.level } : {}),
|
|
5013
|
+
...(input.medium ? { medium: input.medium } : {}),
|
|
5014
|
+
...(input.stitch ? { stitch: input.stitch } : {}),
|
|
5015
|
+
...(input.stitchBudget !== undefined ? { stitchBudget: input.stitchBudget } : {}),
|
|
4264
5016
|
})}`, 'Compose: apply pattern');
|
|
4265
5017
|
}
|
|
4266
5018
|
}
|
|
@@ -4520,6 +5272,12 @@ ${resolve}
|
|
|
4520
5272
|
${guard}
|
|
4521
5273
|
try {
|
|
4522
5274
|
const result = await app.imageTools.applyCutoutStyle(${JSON.stringify(input.itemId || '')}, ${JSON.stringify(input.preset || '')}, ${JSON.stringify(input.options || {})});
|
|
5275
|
+
// An unknown preset returns the very item it was given, unchanged — so a
|
|
5276
|
+
// truthy result proves nothing. The engine records the refusal on it.
|
|
5277
|
+
const cutRej = result && result.data && result.data.cutoutStyleRejected;
|
|
5278
|
+
if (cutRej) {
|
|
5279
|
+
return { success: false, action: 'apply', itemId: ${JSON.stringify(input.itemId || '')}, error: 'Unknown cutout preset "' + cutRej.requested + '" — the item is UNCHANGED.', requested: cutRej.requested, known: cutRej.known };
|
|
5280
|
+
}
|
|
4523
5281
|
return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId || '')}, preset: ${JSON.stringify(input.preset || '')} };
|
|
4524
5282
|
} catch (e) {
|
|
4525
5283
|
return { error: 'Failed to apply cutout style: ' + e.message };
|
|
@@ -4687,49 +5445,260 @@ ${resolve}
|
|
|
4687
5445
|
return `(function() { return { error: 'Unknown query action: ${input.action}' }; })();`;
|
|
4688
5446
|
}
|
|
4689
5447
|
}
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
//
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
5448
|
+
generateQueryCapabilities(input) {
|
|
5449
|
+
const validated = QueryCapabilitiesInputSchema.parse(input);
|
|
5450
|
+
const S = (v) => JSON.stringify(v);
|
|
5451
|
+
// Warming is the engine's default and the honest answer, so the argument is
|
|
5452
|
+
// omitted unless the caller explicitly opted out — no reason to spend bytes
|
|
5453
|
+
// restating a default.
|
|
5454
|
+
const warmArg = validated.warm === undefined ? '' : S({ warm: validated.warm });
|
|
5455
|
+
return `
|
|
5456
|
+
// Query capabilities / recommend treatments from the engine's LIVE registries.
|
|
5457
|
+
//
|
|
5458
|
+
// Reads app.getCapabilities(), which warms the lazily-constructed registries
|
|
5459
|
+
// before answering and returns a coverage report saying what it read from.
|
|
5460
|
+
// \`warm: false\` skips that and answers from whatever is already resident —
|
|
5461
|
+
// cheaper, and the coverage report labels itself \`warmed: false\` when it does.
|
|
5462
|
+
// This tool previously rebuilt that index itself, and because generatorRegistry
|
|
5463
|
+
// is null until ensureHeavyModules() lands (~1.2s after boot) and the rigging /
|
|
5464
|
+
// blending / deform relation rules only register when their subsystem is first
|
|
5465
|
+
// touched, an early call reported ZERO generators and ~77 of ~100 relations as
|
|
5466
|
+
// if that were the whole engine.
|
|
5467
|
+
(async function() {
|
|
5468
|
+
const action = ${S(validated.action || 'list')};
|
|
5469
|
+
const kind = ${S(validated.kind)};
|
|
5470
|
+
const mood = ${S(validated.mood)};
|
|
5471
|
+
const subject = ${S(validated.subject)};
|
|
5472
|
+
const avoid = ${S(validated.avoid)};
|
|
5473
|
+
const seed = ${S(validated.seed)};
|
|
5474
|
+
const key = ${S(validated.key)};
|
|
5475
|
+
const exclude = ${S(validated.exclude)};
|
|
5476
|
+
|
|
5477
|
+
// WHICH ENGINE METHODS THIS STUDIO HAS.
|
|
5478
|
+
//
|
|
5479
|
+
// Every tool guards the methods it needs and refuses by name without them,
|
|
5480
|
+
// which is right per call and useless in advance: an agent otherwise learns a
|
|
5481
|
+
// studio is too old one tool at a time. getCapabilities cannot answer this —
|
|
5482
|
+
// it reports the engine's VOCABULARY (styles, palettes, relations), not which
|
|
5483
|
+
// of its methods exist.
|
|
5484
|
+
//
|
|
5485
|
+
// The list is DERIVED from the emitters' own guards by
|
|
5486
|
+
// scripts/sync-engine-methods.mjs, so it cannot drift from what the tools
|
|
5487
|
+
// actually check. Required and optional are kept apart because an older
|
|
5488
|
+
// studio that degrades is not an unusable one.
|
|
5489
|
+
if (action === 'studio') {
|
|
5490
|
+
const required = ${S(REQUIRED_ENGINE_METHODS)};
|
|
5491
|
+
const optional = ${S(OPTIONAL_ENGINE_METHODS)};
|
|
5492
|
+
const has = (path) => {
|
|
5493
|
+
// Dotted paths: thirteen methods live behind a sub-facade
|
|
5494
|
+
// (exportEngine.exportToStore, mapSystem.worldTour).
|
|
5495
|
+
let node = app;
|
|
5496
|
+
for (const part of path.split('.')) {
|
|
5497
|
+
if (!node) return false;
|
|
5498
|
+
node = node[part];
|
|
5499
|
+
}
|
|
5500
|
+
return typeof node === 'function';
|
|
5501
|
+
};
|
|
5502
|
+
const missingRequired = required.filter((m) => !has(m));
|
|
5503
|
+
const missingOptional = optional.filter((m) => !has(m));
|
|
5504
|
+
return {
|
|
5505
|
+
success: true,
|
|
5506
|
+
studio: {
|
|
5507
|
+
requiredTotal: required.length,
|
|
5508
|
+
requiredPresent: required.length - missingRequired.length,
|
|
5509
|
+
missingRequired: missingRequired,
|
|
5510
|
+
optionalTotal: optional.length,
|
|
5511
|
+
missingOptional: missingOptional,
|
|
5512
|
+
// A studio missing nothing required can serve every tool this server
|
|
5513
|
+
// publishes. Missing something optional only narrows an answer.
|
|
5514
|
+
complete: missingRequired.length === 0,
|
|
5515
|
+
},
|
|
5516
|
+
};
|
|
5517
|
+
}
|
|
5518
|
+
|
|
5519
|
+
// The registries getCapabilities does NOT aggregate. Each is a capability an
|
|
5520
|
+
// agent otherwise has to guess at, which by this project's rule is the same
|
|
5521
|
+
// as its not existing. Read one at a time and name a missing facade rather
|
|
5522
|
+
// than answering with an empty list, which would read as "none exist".
|
|
5523
|
+
if (action === 'catalogue') {
|
|
5524
|
+
const READERS = {
|
|
5525
|
+
rig_presets: 'listRigPresets', shader_effects: 'listShaderEffects',
|
|
5526
|
+
stroke_decorations: 'listStrokeDecorations', precomps: 'listPrecomps',
|
|
5527
|
+
images: 'listImages', segment_edit_kinds: 'listSegmentEditKinds',
|
|
5528
|
+
shatter_orders: 'listShatterOrders', world_meshes: 'listWorldMeshes',
|
|
5529
|
+
relation_presets: 'loadRelationPresets',
|
|
5530
|
+
};
|
|
5531
|
+
const want = ${S(validated.catalogue)};
|
|
5532
|
+
const fn = READERS[want];
|
|
5533
|
+
if (!fn) { return { success: false, error: 'unknown catalogue ' + JSON.stringify(want) + ' — try ' + Object.keys(READERS).join(', ') }; }
|
|
5534
|
+
if (typeof app[fn] !== 'function') { return { success: false, error: 'app.' + fn + ' unavailable — update FxTool' }; }
|
|
5535
|
+
const entries = await app[fn]();
|
|
5536
|
+
// loadRelationPresets answers {presets, rejected, source} rather than a bare
|
|
5537
|
+
// array, and REJECTED is the half worth surfacing: a preset this build
|
|
5538
|
+
// cannot use is a named absence, where dropping it silently would read as
|
|
5539
|
+
// the catalogue simply being smaller.
|
|
5540
|
+
if (entries && !Array.isArray(entries) && Array.isArray(entries.presets)) {
|
|
5541
|
+
return {
|
|
5542
|
+
success: true, catalogue: want, reader: 'app.' + fn,
|
|
5543
|
+
count: entries.presets.length, entries: entries.presets,
|
|
5544
|
+
rejected: entries.rejected || [], source: entries.source || null,
|
|
5545
|
+
};
|
|
5546
|
+
}
|
|
5547
|
+
return { success: true, catalogue: want, reader: 'app.' + fn, count: Array.isArray(entries) ? entries.length : undefined, entries: entries };
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
if (typeof app.getCapabilities !== 'function') {
|
|
5551
|
+
return { success: false, error: 'app.getCapabilities unavailable — update FxTool' };
|
|
5552
|
+
}
|
|
5553
|
+
|
|
5554
|
+
let caps = [], report = null;
|
|
5555
|
+
try {
|
|
5556
|
+
const res = await app.getCapabilities(${warmArg});
|
|
5557
|
+
caps = (res && res.capabilities) || [];
|
|
5558
|
+
report = (res && res.coverage) || null;
|
|
5559
|
+
} catch (e) {
|
|
5560
|
+
return { success: false, error: 'Failed to inspect capabilities: ' + e.message };
|
|
5561
|
+
}
|
|
5562
|
+
|
|
5563
|
+
if (action === 'coverage') {
|
|
5564
|
+
// The engine's own report: totals per kind, the entries that can be applied
|
|
5565
|
+
// but not RANKED (no label, no definition), the kinds with no source wired,
|
|
5566
|
+
// and which lazy registries were live when it answered.
|
|
5567
|
+
return { success: true, action: 'coverage', ...(report || {}), total: caps.length };
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
if (action === 'find') {
|
|
5571
|
+
if (!key) return { success: false, error: 'find action requires key' };
|
|
5572
|
+
const found = caps.find(c => c.key === String(key));
|
|
5573
|
+
return found ? { success: true, action: 'find', capability: found } : { success: false, error: 'Capability not found: ' + key };
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
let filtered = caps;
|
|
5577
|
+
if (kind) {
|
|
5578
|
+
const kinds = new Set(Array.isArray(kind) ? kind : [kind]);
|
|
5579
|
+
filtered = caps.filter(c => kinds.has(c.kind));
|
|
5580
|
+
}
|
|
5581
|
+
|
|
5582
|
+
if (action === 'list') {
|
|
5583
|
+
return {
|
|
5584
|
+
success: true,
|
|
5585
|
+
action: 'list',
|
|
5586
|
+
count: filtered.length,
|
|
5587
|
+
total: caps.length,
|
|
5588
|
+
unwiredKinds: report ? report.unwiredKinds : undefined,
|
|
5589
|
+
capabilities: filtered.map(c => ({
|
|
5590
|
+
key: c.key, kind: c.kind, label: c.label,
|
|
5591
|
+
definition: c.definition, applyWith: c.applyWith, describable: c.describable
|
|
5592
|
+
}))
|
|
5593
|
+
};
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
if (action === 'choose') {
|
|
5597
|
+
const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'into', 'at', 'on', 'as', 'is', 'are', 'then', 'their', 'they', 'it', 'its', 'each', 'from', 'with', 'that', 'characters', 'character', 'letters', 'letter', 'text', 'frame', 'real']);
|
|
5598
|
+
const tokenize = (t) => String(t || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2 && !STOP.has(w));
|
|
5599
|
+
const MOOD_TERMS = {
|
|
5600
|
+
reveal: ['resolve', 'resolves', 'reveal', 'appear', 'emerge', 'decode', 'decrypt', 'unscramble', 'form', 'assemble', 'converge'],
|
|
5601
|
+
triumphant: ['burst', 'launch', 'firework', 'bloom', 'spark', 'explode', 'shine', 'glow', 'celebrate', 'rise'],
|
|
5602
|
+
failure: ['crumble', 'fall', 'collapse', 'burn', 'decay', 'dissolve', 'break', 'shatter', 'sink', 'error', 'corrupt'],
|
|
5603
|
+
calm: ['drift', 'float', 'gentle', 'slow', 'settle', 'fade', 'wave', 'ripple', 'pour'],
|
|
5604
|
+
technical: ['binary', 'matrix', 'grid', 'scan', 'code', 'digital', 'circuit', 'data', 'print', 'terminal'],
|
|
5605
|
+
energetic: ['bounce', 'swarm', 'spray', 'shoot', 'fast', 'rapid', 'scatter', 'slam', 'volley']
|
|
5606
|
+
};
|
|
5607
|
+
|
|
5608
|
+
// Deterministic tiebreak. Equal-scoring candidates used to fall out in
|
|
5609
|
+
// array order, which made \`seed\` — documented as giving reproducible
|
|
5610
|
+
// selection — do nothing at all. FNV-1a over seed+key gives a stable
|
|
5611
|
+
// shuffle per seed; with no seed the order is stable by key, so a repeated
|
|
5612
|
+
// call still answers the same way.
|
|
5613
|
+
const tiebreak = (k) => {
|
|
5614
|
+
if (!seed) return null;
|
|
5615
|
+
let h = 2166136261;
|
|
5616
|
+
const s = String(seed) + '|' + k;
|
|
5617
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
5618
|
+
return h >>> 0;
|
|
5619
|
+
};
|
|
5620
|
+
|
|
5621
|
+
const exSet = new Set(Array.isArray(exclude) ? exclude : []);
|
|
5622
|
+
const scored = filtered.filter(c => !exSet.has(c.key)).map(c => {
|
|
5623
|
+
const words = new Set(tokenize(c.text || (c.label + ' ' + c.definition)));
|
|
5624
|
+
if (!words.size) return { item: c, score: 0 };
|
|
5625
|
+
let score = 0;
|
|
5626
|
+
const moods = Array.isArray(mood) ? mood : (mood ? [mood] : []);
|
|
5627
|
+
for (const m of moods) {
|
|
5628
|
+
for (const term of (MOOD_TERMS[m] || [])) { if (words.has(term)) score += 12; }
|
|
5629
|
+
}
|
|
5630
|
+
for (const w of tokenize(subject)) { if (words.has(w)) score += 7; }
|
|
5631
|
+
for (const av of (Array.isArray(avoid) ? avoid : (avoid ? [avoid] : []))) {
|
|
5632
|
+
for (const term of (MOOD_TERMS[av] || [])) { if (words.has(term)) score -= 10; }
|
|
5633
|
+
}
|
|
5634
|
+
return { item: c, score };
|
|
5635
|
+
});
|
|
5636
|
+
|
|
5637
|
+
scored.sort((a, b) => {
|
|
5638
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
5639
|
+
const ha = tiebreak(a.item.key), hb = tiebreak(b.item.key);
|
|
5640
|
+
if (ha !== null && ha !== hb) return ha - hb;
|
|
5641
|
+
return a.item.key.localeCompare(b.item.key);
|
|
5642
|
+
});
|
|
5643
|
+
const top = scored.slice(0, 5).map(s => ({ ...s.item, score: s.score }));
|
|
5644
|
+
return {
|
|
5645
|
+
success: true,
|
|
5646
|
+
action: 'choose',
|
|
5647
|
+
recommendation: top[0] || null,
|
|
5648
|
+
topCandidates: top,
|
|
5649
|
+
// A chooser scores on description, so entries without one can never be
|
|
5650
|
+
// recommended. Saying so beats silently never offering them.
|
|
5651
|
+
unrankable: report ? report.unrankable : undefined
|
|
5652
|
+
};
|
|
5653
|
+
}
|
|
5654
|
+
|
|
5655
|
+
return { success: false, error: 'Unknown capabilities action: ' + action };
|
|
5656
|
+
})();
|
|
5657
|
+
`.trim();
|
|
5658
|
+
}
|
|
5659
|
+
// ===========================================================================
|
|
5660
|
+
// DEFORMATION
|
|
5661
|
+
// ===========================================================================
|
|
5662
|
+
generateDeform(input) {
|
|
5663
|
+
const guard = `if (!app.deformPresets) return { error: 'DeformPresets not available' };`;
|
|
5664
|
+
switch (input.action) {
|
|
5665
|
+
case 'apply': {
|
|
5666
|
+
if (!input.itemId)
|
|
5667
|
+
return `(function() { return { error: 'itemId is required for deform apply' }; })();`;
|
|
5668
|
+
if (!input.preset)
|
|
5669
|
+
return `(function() { return { error: 'preset is required for deform apply' }; })();`;
|
|
5670
|
+
const params = {};
|
|
5671
|
+
if (input.frequency !== undefined)
|
|
5672
|
+
params.frequency = input.frequency;
|
|
5673
|
+
if (input.amplitude !== undefined)
|
|
5674
|
+
params.amplitude = input.amplitude;
|
|
5675
|
+
if (input.phase)
|
|
5676
|
+
params.phase = input.phase;
|
|
5677
|
+
if (input.loop !== undefined)
|
|
5678
|
+
params.loop = input.loop;
|
|
5679
|
+
if (input.axis)
|
|
5680
|
+
params.axis = input.axis;
|
|
5681
|
+
if (input.turns !== undefined)
|
|
5682
|
+
params.turns = input.turns;
|
|
5683
|
+
if (input.waves !== undefined)
|
|
5684
|
+
params.waves = input.waves;
|
|
5685
|
+
if (input.maxDisplacement !== undefined)
|
|
5686
|
+
params.maxDisplacement = input.maxDisplacement;
|
|
5687
|
+
if (input.speed !== undefined)
|
|
5688
|
+
params.speed = input.speed;
|
|
5689
|
+
if (input.steps !== undefined)
|
|
5690
|
+
params.steps = input.steps;
|
|
5691
|
+
const itemIdStr = JSON.stringify(input.itemId);
|
|
5692
|
+
return `
|
|
5693
|
+
// Apply deformation preset
|
|
5694
|
+
(function() {
|
|
5695
|
+
${guard}
|
|
5696
|
+
const entry = app.itemRegistry.get(${itemIdStr});
|
|
5697
|
+
if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
|
|
5698
|
+
app.deformPresets.apply(entry.item, '${input.preset}', ${JSON.stringify(params)});
|
|
5699
|
+
return { success: true, action: 'apply', itemId: ${itemIdStr}, preset: '${input.preset}' };
|
|
5700
|
+
})();`.trim();
|
|
5701
|
+
}
|
|
4733
5702
|
case 'trigger': {
|
|
4734
5703
|
if (!input.itemId)
|
|
4735
5704
|
return `(function() { return { error: 'itemId is required for deform trigger' }; })();`;
|
|
@@ -5227,6 +6196,7 @@ ${resolve}
|
|
|
5227
6196
|
* (bringToFront / sendToBack / bringForward / sendBackward).
|
|
5228
6197
|
*/
|
|
5229
6198
|
generateArrange(input) {
|
|
6199
|
+
// @engine-methods bringToFront sendToBack bringForward sendBackward
|
|
5230
6200
|
const method = {
|
|
5231
6201
|
front: 'bringToFront',
|
|
5232
6202
|
back: 'sendToBack',
|
|
@@ -5465,9 +6435,41 @@ ${resolve}
|
|
|
5465
6435
|
}).compile(input.doc);
|
|
5466
6436
|
const opsJson = JSON.stringify(ops, null, 2);
|
|
5467
6437
|
const diagJson = JSON.stringify(diagnostics);
|
|
6438
|
+
const docJson = JSON.stringify(input.doc);
|
|
6439
|
+
// awaitImages: the studio decorates a raster (fit, keyframes, mask) on its
|
|
6440
|
+
// 'load' event and re-runs its finalize steps when the last one lands; a
|
|
6441
|
+
// tool call that screenshots next wants that to have happened.
|
|
6442
|
+
const engineOptsJson = JSON.stringify({ canvas: input.canvas, defaultGeometry: input.defaultGeometry, awaitImages: true });
|
|
5468
6443
|
return `
|
|
5469
6444
|
// Instantiate ontology → scene (${ops.filter((o) => o.op === 'create').length} items, ${ops.filter((o) => o.op === 'addRelation').length} relations)
|
|
5470
|
-
(function() {
|
|
6445
|
+
(async function() {
|
|
6446
|
+
// Engine door first: the studio's own OntologyCompiler reads every node facet
|
|
6447
|
+
// (geometry, paint, text, the keyframe track, masks) and builds through the
|
|
6448
|
+
// template loader's paths — the round trip the studio's pixel tests verify
|
|
6449
|
+
// (e2e/graph-instantiate-parity.spec.js). The server-side compile above is
|
|
6450
|
+
// kept for the diagnostics preview and for studios that predate the facade,
|
|
6451
|
+
// which get the box loop below and a note saying so.
|
|
6452
|
+
const doc = ${docJson};
|
|
6453
|
+
if (typeof app.instantiateOntology === 'function') {
|
|
6454
|
+
const r = await app.instantiateOntology(doc, ${engineOptsJson});
|
|
6455
|
+
const diags = r.diagnostics || [];
|
|
6456
|
+
return {
|
|
6457
|
+
success: !diags.some(function(d) { return d && d.level === 'error'; }) && !r.imagesFailed,
|
|
6458
|
+
engine: 'studio',
|
|
6459
|
+
itemIds: r.itemIds, itemCount: r.itemIds.length,
|
|
6460
|
+
// Every count the engine returns, not a chosen three. imagesFailed is the
|
|
6461
|
+
// one that matters most: a raster that never decoded leaves a scene that
|
|
6462
|
+
// looks built and is not, and dropping the count is how that becomes
|
|
6463
|
+
// invisible. relationsApplied and connectorsApplied are the only way to
|
|
6464
|
+
// tell "the doc declared no edges" from "the edges did not bind".
|
|
6465
|
+
keyframesApplied: r.keyframesApplied, masksApplied: r.masksApplied, deferred: r.deferred,
|
|
6466
|
+
relationsApplied: r.relationsApplied, connectorsApplied: r.connectorsApplied,
|
|
6467
|
+
interactionsApplied: r.interactionsApplied, effectsApplied: r.effectsApplied,
|
|
6468
|
+
groupsCreated: r.groupsCreated, nested: r.nested,
|
|
6469
|
+
imagesFailed: r.imagesFailed, backgroundApplied: r.backgroundApplied, duration: r.duration,
|
|
6470
|
+
diagnostics: diags, errors: [],
|
|
6471
|
+
};
|
|
6472
|
+
}
|
|
5471
6473
|
if (typeof app.create !== 'function' || typeof app.addRelation !== 'function') {
|
|
5472
6474
|
return { success: false, error: 'app.create / app.addRelation unavailable' };
|
|
5473
6475
|
}
|
|
@@ -5488,7 +6490,7 @@ ${resolve}
|
|
|
5488
6490
|
}
|
|
5489
6491
|
}
|
|
5490
6492
|
if (app.historyManager) app.historyManager.saveState();
|
|
5491
|
-
return { success: errors.length === 0, itemIds: itemIds, itemCount: itemIds.length, diagnostics: diagnostics, errors: errors };
|
|
6493
|
+
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 };
|
|
5492
6494
|
})();`.trim();
|
|
5493
6495
|
}
|
|
5494
6496
|
/**
|
|
@@ -5709,6 +6711,243 @@ ${guard}
|
|
|
5709
6711
|
palettes: typeof app.listTextPalettes === 'function' ? app.listTextPalettes() : [],
|
|
5710
6712
|
fontAxes: typeof app.listFontAxes === 'function' ? app.listFontAxes() : null,
|
|
5711
6713
|
};
|
|
6714
|
+
})();`.trim();
|
|
6715
|
+
}
|
|
6716
|
+
case 'cursive':
|
|
6717
|
+
return `
|
|
6718
|
+
// Text: write it as STROKED handwriting — a path, not a glyph
|
|
6719
|
+
(function() {
|
|
6720
|
+
if (typeof app.createCursiveText !== 'function') { return { success: false, error: 'app.createCursiveText unavailable — update FxTool' }; }
|
|
6721
|
+
const item = app.createCursiveText(${S(input.text)}, ${S(input.cursiveOptions ?? {})});
|
|
6722
|
+
if (!item) { return { success: false, error: 'the cursive text produced no path' }; }
|
|
6723
|
+
return { success: true, itemId: item.data && item.data.id };
|
|
6724
|
+
})();`.trim();
|
|
6725
|
+
case 'wrap':
|
|
6726
|
+
return `
|
|
6727
|
+
// Text: break to a width — reversibly
|
|
6728
|
+
(function() {
|
|
6729
|
+
if (typeof app.wrapText !== 'function') { return { success: false, error: 'app.wrapText unavailable — update FxTool' }; }
|
|
6730
|
+
const r = app.wrapText(${S(input.itemId)}, ${S(input.maxWidth)});
|
|
6731
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'that item could not be wrapped' }; }
|
|
6732
|
+
return { success: true, ...r };
|
|
6733
|
+
})();`.trim();
|
|
6734
|
+
case 'unwrap':
|
|
6735
|
+
return `
|
|
6736
|
+
// Text: restore the single line wrap replaced
|
|
6737
|
+
(function() {
|
|
6738
|
+
if (typeof app.unwrapText !== 'function') { return { success: false, error: 'app.unwrapText unavailable — update FxTool' }; }
|
|
6739
|
+
// 'not wrapped' is the engine's own refusal and is more useful than
|
|
6740
|
+
// anything this layer could infer, so it is passed through.
|
|
6741
|
+
const r = app.unwrapText(${S(input.itemId)});
|
|
6742
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'that item is not wrapped' }; }
|
|
6743
|
+
return { success: true, ...r };
|
|
6744
|
+
})();`.trim();
|
|
6745
|
+
case 'to_collage':
|
|
6746
|
+
return `
|
|
6747
|
+
// Text: turn an EXISTING text item into a letter collage, in place
|
|
6748
|
+
(function() {
|
|
6749
|
+
if (typeof app.convertTextToCollage !== 'function') { return { success: false, error: 'app.convertTextToCollage unavailable — update FxTool' }; }
|
|
6750
|
+
const item = app.getItemById ? app.getItemById(${S(input.itemId)}) : null;
|
|
6751
|
+
if (!item) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
6752
|
+
// Returns null when there is no letterCollage subsystem — a bare null that
|
|
6753
|
+
// would otherwise read as "the conversion did nothing to your text".
|
|
6754
|
+
const r = app.convertTextToCollage(item, ${S(input.text ?? null)} || item.content, ${S(input.collageOptions ?? {})});
|
|
6755
|
+
if (!r) { return { success: false, error: 'the collage system is unavailable in this build, so the text was left as it was' }; }
|
|
6756
|
+
return { success: true, collage: r };
|
|
6757
|
+
})();`.trim();
|
|
6758
|
+
}
|
|
6759
|
+
}
|
|
6760
|
+
/**
|
|
6761
|
+
* pinepaper_text_effect — the 37 character-level text effects.
|
|
6762
|
+
*
|
|
6763
|
+
* `applyTextEffect` is ASYNC (it lazy-`import()`s the effects chunk), so this
|
|
6764
|
+
* emits an async IIFE — a sync wrapper would hand the bridge a pending
|
|
6765
|
+
* Promise instead of the result.
|
|
6766
|
+
*
|
|
6767
|
+
* The returned `items` are live Paper objects and cannot cross the bridge;
|
|
6768
|
+
* only ids/count/duration are forwarded. `ids` is the whole handle set the
|
|
6769
|
+
* caller now has, since the source item is gone.
|
|
6770
|
+
*/
|
|
6771
|
+
/**
|
|
6772
|
+
* The medium axis. `list_media` and `resolve` are READS — they exist so an
|
|
6773
|
+
* agent can find out what this build can honestly make before it promises a
|
|
6774
|
+
* medium in prose, which is the failure the axis was built to prevent.
|
|
6775
|
+
*/
|
|
6776
|
+
generateDesignMedium(input) {
|
|
6777
|
+
const S = (v) => JSON.stringify(v);
|
|
6778
|
+
const wrap = (comment, body) => `\n// ${comment}\n(function() {\n${body}\n})();`.trim();
|
|
6779
|
+
switch (input.action) {
|
|
6780
|
+
case 'list_media':
|
|
6781
|
+
return wrap('Medium: list', ` if (typeof app.listDesignMedia !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
|
|
6782
|
+
const media = app.listDesignMedia();
|
|
6783
|
+
return { success: true, action: 'list_media', media: media, native: media.filter(function(m) { return m.fidelity === 'native'; }).map(function(m) { return m.key; }) };`);
|
|
6784
|
+
case 'resolve':
|
|
6785
|
+
return wrap('Medium: resolve', ` if (typeof app.resolveDesignMedium !== 'function') { return { success: false, error: 'medium axis unavailable — update FxTool' }; }
|
|
6786
|
+
const v = app.resolveDesignMedium(${S(input.medium)});
|
|
6787
|
+
return Object.assign({ success: true, action: 'resolve' }, v);`);
|
|
6788
|
+
case 'list_stitches':
|
|
6789
|
+
return wrap('Medium: list stitches', ` if (typeof app.listStitches !== 'function') { return { success: false, error: 'thread medium unavailable — update FxTool' }; }
|
|
6790
|
+
const stitches = app.listStitches();
|
|
6791
|
+
return { success: true, action: 'list_stitches', stitches: stitches, count: stitches.length };`);
|
|
6792
|
+
case 'apply_thread': {
|
|
6793
|
+
const opts = S({
|
|
6794
|
+
...(input.stitch !== undefined ? { stitch: input.stitch } : {}),
|
|
6795
|
+
...(input.field !== undefined ? { field: input.field } : {}),
|
|
6796
|
+
...(input.stitchLen !== undefined ? { stitchLen: input.stitchLen } : {}),
|
|
6797
|
+
...(input.rowGap !== undefined ? { rowGap: input.rowGap } : {}),
|
|
6798
|
+
...(input.variance !== undefined ? { variance: input.variance } : {}),
|
|
6799
|
+
...(input.width !== undefined ? { width: input.width } : {}),
|
|
6800
|
+
...(input.sheen !== undefined ? { sheen: input.sheen } : {}),
|
|
6801
|
+
...(input.color !== undefined ? { color: input.color } : {}),
|
|
6802
|
+
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
6803
|
+
...(input.count !== undefined ? { count: input.count } : {}),
|
|
6804
|
+
...(input.slant !== undefined ? { slant: input.slant } : {}),
|
|
6805
|
+
...(input.overlap !== undefined ? { overlap: input.overlap } : {}),
|
|
6806
|
+
...(input.stagger !== undefined ? { stagger: input.stagger } : {}),
|
|
6807
|
+
...(input.gapLen !== undefined ? { gapLen: input.gapLen } : {}),
|
|
6808
|
+
...(input.gridSize !== undefined ? { gridSize: input.gridSize } : {}),
|
|
6809
|
+
});
|
|
6810
|
+
const roughStr = JSON.stringify(input.roughness ?? 0);
|
|
6811
|
+
const seedStr = JSON.stringify(input.seed ?? 1);
|
|
6812
|
+
return wrap('Medium: render in thread', ` if (typeof app.applyThreadPainting !== 'function') { return { success: false, error: 'thread medium unavailable — update FxTool' }; }
|
|
6813
|
+
const entry = app.itemRegistry && app.itemRegistry.get(${S(input.itemId)});
|
|
6814
|
+
const src = entry && entry.item;
|
|
6815
|
+
if (!src) { return { success: false, action: 'apply_thread', error: 'no item ' + ${S(input.itemId)} }; }
|
|
6816
|
+
// Check the shape BEFORE calling, so the refusal can name the fix. The
|
|
6817
|
+
// engine warns to a console production strips, which an agent never sees.
|
|
6818
|
+
if (src.content !== undefined && typeof src.getPointAt !== 'function') {
|
|
6819
|
+
return { success: false, action: 'apply_thread', error: 'a text item has no outline to stitch. Convert it to glyph paths first (pinepaper_text_style), then stitch the result — the collage it produces is a group of closed paths, which this accepts.' };
|
|
6820
|
+
}
|
|
6821
|
+
const g = app.applyThreadPainting(${S(input.itemId)}, ${opts});
|
|
6822
|
+
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.)' }; }
|
|
6823
|
+
// HAND WOBBLE, applied here rather than in the engine, which has no such
|
|
6824
|
+
// option. Every point of every stitch gets its own phase off the seed, so a
|
|
6825
|
+
// long contour wobbles along its whole length instead of shearing at one end,
|
|
6826
|
+
// and the same seed sews the same irregularities every run.
|
|
6827
|
+
const rough = ${roughStr};
|
|
6828
|
+
if (rough > 0 && g.children) {
|
|
6829
|
+
const rseed = ${seedStr};
|
|
6830
|
+
for (let i = 0; i < g.children.length; i++) {
|
|
6831
|
+
const line = g.children[i];
|
|
6832
|
+
if (!line || !line.segments) continue;
|
|
6833
|
+
for (let j = 0; j < line.segments.length; j++) {
|
|
6834
|
+
line.segments[j].point.x += Math.sin(rseed + i * 2.13 + j * 1.71) * rough * 0.4;
|
|
6835
|
+
line.segments[j].point.y += Math.cos(rseed + i * 3.47 + j * 2.29) * rough * 0.4;
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
}
|
|
6839
|
+
return { success: true, action: 'apply_thread', groupId: (g.data && g.data.id) || null, stitches: g.children.length, roughness: rough };`);
|
|
6840
|
+
}
|
|
6841
|
+
case 'list_flow_fields':
|
|
6842
|
+
return wrap('Medium: list flow fields', ` if (typeof app.listFlowFields !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
|
|
6843
|
+
const fields = app.listFlowFields();
|
|
6844
|
+
return { success: true, action: 'list_flow_fields', fields: fields, count: fields.length };`);
|
|
6845
|
+
case 'list_hatch_options':
|
|
6846
|
+
return wrap('Medium: hatch defaults', ` if (typeof app.listHatchOptions !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
|
|
6847
|
+
return { success: true, action: 'list_hatch_options', defaults: app.listHatchOptions() };`);
|
|
6848
|
+
case 'apply_hatch': {
|
|
6849
|
+
const opts = S({
|
|
6850
|
+
...(input.distance !== undefined ? { distance: input.distance } : {}),
|
|
6851
|
+
...(input.angle !== undefined ? { angle: input.angle } : {}),
|
|
6852
|
+
...(input.gradient !== undefined ? { gradient: input.gradient } : {}),
|
|
6853
|
+
...(input.rand !== undefined ? { rand: input.rand } : {}),
|
|
6854
|
+
...(input.continuous !== undefined ? { continuous: input.continuous } : {}),
|
|
6855
|
+
...(input.flowField !== undefined ? { field: input.flowField } : {}),
|
|
6856
|
+
...(input.t !== undefined ? { t: input.t } : {}),
|
|
6857
|
+
...(input.width !== undefined ? { width: input.width } : {}),
|
|
6858
|
+
...(input.color !== undefined ? { color: input.color } : {}),
|
|
6859
|
+
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
6860
|
+
});
|
|
6861
|
+
return wrap('Medium: rule an item with hatching', ` if (typeof app.applyHatching !== 'function') { return { success: false, error: 'hatch medium unavailable — update FxTool' }; }
|
|
6862
|
+
const entry = app.itemRegistry && app.itemRegistry.get(${S(input.itemId)});
|
|
6863
|
+
const src = entry && entry.item;
|
|
6864
|
+
if (!src) { return { success: false, action: 'apply_hatch', error: 'no item ' + ${S(input.itemId)} }; }
|
|
6865
|
+
// Every refusal below is one the engine only console.warns, and production
|
|
6866
|
+
// strips the console — so an agent would otherwise get a bare null and no
|
|
6867
|
+
// idea which of five different things went wrong.
|
|
6868
|
+
if (src.content !== undefined && typeof src.getPointAt !== 'function') {
|
|
6869
|
+
return { success: false, action: 'apply_hatch', error: 'a text item has no outline to hatch. Convert it to glyph paths first (pinepaper_text_style), then hatch the result — the collage it produces is a group of closed paths, which this accepts.' };
|
|
6870
|
+
}
|
|
6871
|
+
// Count the closed paths BEFORE the call, so a truncated group can be
|
|
6872
|
+
// reported as truncated rather than looking complete.
|
|
6873
|
+
let closed = 0;
|
|
6874
|
+
const isGroup = src.children && !(typeof src.getPointAt === 'function' && src.closed);
|
|
6875
|
+
if (isGroup) {
|
|
6876
|
+
const walk = function(n) {
|
|
6877
|
+
for (const c of (n.children || [])) {
|
|
6878
|
+
if (c.children && typeof c.getPointAt !== 'function') walk(c);
|
|
6879
|
+
else if (typeof c.getPointAt === 'function' && c.closed && c.length > 0) closed++;
|
|
6880
|
+
}
|
|
6881
|
+
};
|
|
6882
|
+
walk(src);
|
|
6883
|
+
if (!closed) { return { success: false, action: 'apply_hatch', error: 'that group contains no closed paths to hatch — hatching needs a closed outline, and open strokes, rasters and empty groups all land here.' }; }
|
|
6884
|
+
}
|
|
6885
|
+
const b = src.bounds;
|
|
6886
|
+
const g = app.applyHatching(${S(input.itemId)}, ${opts});
|
|
6887
|
+
if (!g) {
|
|
6888
|
+
// Spacing wider than the shape is the usual cause and is otherwise a
|
|
6889
|
+
// silent no-op: the item just vanishes behind an empty group.
|
|
6890
|
+
return { success: false, action: 'apply_hatch', error: 'nothing was ruled. Most often the spacing exceeds the shape — distance is ' + ${S(input.distance ?? 6)} + 'px against bounds ' + Math.round(b.width) + 'x' + Math.round(b.height) + '. Otherwise the item has no closed outline: hatching needs a closed path, a compound path, or a group of them.' };
|
|
6891
|
+
}
|
|
6892
|
+
const parts = (g.data && g.data.parts) || null;
|
|
6893
|
+
return {
|
|
6894
|
+
success: true,
|
|
6895
|
+
action: 'apply_hatch',
|
|
6896
|
+
groupId: (g.data && g.data.id) || null,
|
|
6897
|
+
lines: g.children ? g.children.length : 0,
|
|
6898
|
+
angle: (g.data && g.data.angle) !== undefined ? g.data.angle : null,
|
|
6899
|
+
spacing: (g.data && g.data.spacing) !== undefined ? g.data.spacing : null,
|
|
6900
|
+
// The engine hatches at most 40 paths from a group and warns about the rest.
|
|
6901
|
+
...(parts !== null ? { parts: parts } : {}),
|
|
6902
|
+
...(closed > 40 ? { truncated: 'hatched the first 40 of ' + closed + ' closed paths' } : {}),
|
|
6903
|
+
// Re-hatching REPLACES: a second set of lines over the first is
|
|
6904
|
+
// indistinguishable from nothing having happened, so trying spacings in
|
|
6905
|
+
// turn is safe and is the flow this exists for.
|
|
6906
|
+
note: 'the source item is now hidden behind the hatching; re-hatching replaces rather than stacks',
|
|
6907
|
+
};`);
|
|
6908
|
+
}
|
|
6909
|
+
default:
|
|
6910
|
+
return wrap('Medium: unknown action', ` return { success: false, error: 'unknown action' };`);
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
generateTextEffect(input) {
|
|
6914
|
+
const S = (v) => JSON.stringify(v);
|
|
6915
|
+
switch (input.action) {
|
|
6916
|
+
case 'apply': {
|
|
6917
|
+
// options spreads FIRST so the named fields win on collision — the
|
|
6918
|
+
// schema advertises that precedence, so it has to be real here.
|
|
6919
|
+
const opts = S({
|
|
6920
|
+
...(input.options ?? {}),
|
|
6921
|
+
...(input.duration !== undefined ? { duration: input.duration } : {}),
|
|
6922
|
+
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
6923
|
+
...(input.gradient !== undefined ? { gradient: input.gradient } : {}),
|
|
6924
|
+
...(input.gradientStops !== undefined ? { gradientStops: input.gradientStops } : {}),
|
|
6925
|
+
...(input.gradientDirection !== undefined ? { gradientDirection: input.gradientDirection } : {}),
|
|
6926
|
+
...(input.gradientSteps !== undefined ? { gradientSteps: input.gradientSteps } : {}),
|
|
6927
|
+
...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
|
|
6928
|
+
});
|
|
6929
|
+
return `
|
|
6930
|
+
// Text effect: ${input.effect} — the text becomes one animated item PER CHARACTER
|
|
6931
|
+
(async function() {
|
|
6932
|
+
if (typeof app.applyTextEffect !== 'function') { return { success: false, error: 'app.applyTextEffect unavailable — update FxTool to a text-effects build' }; }
|
|
6933
|
+
const r = await app.applyTextEffect(${S(input.itemId)}, ${S(input.effect)}, ${opts});
|
|
6934
|
+
if (!r || !r.ok) { return { success: false, error: (r && r.error) || 'text effect failed' }; }
|
|
6935
|
+
// r.items are live Paper objects — NOT serializable, deliberately dropped.
|
|
6936
|
+
// The source item id is GONE unless keepSource was set, so r.ids is now the
|
|
6937
|
+
// caller's entire handle set; returning it is what makes the tool usable.
|
|
6938
|
+
return {
|
|
6939
|
+
success: true, action: 'apply', effect: r.effect, ids: r.ids, count: r.count,
|
|
6940
|
+
duration: r.duration, sourceRemoved: ${S(input.keepSource !== true)},
|
|
6941
|
+
};
|
|
6942
|
+
})();`.trim();
|
|
6943
|
+
}
|
|
6944
|
+
case 'list': {
|
|
6945
|
+
return `
|
|
6946
|
+
// The 37 character-level text effects — the picker surface
|
|
6947
|
+
(async function() {
|
|
6948
|
+
if (typeof app.listTextEffects !== 'function') { return { success: false, error: 'app.listTextEffects unavailable — update FxTool to a text-effects build' }; }
|
|
6949
|
+
const effects = await app.listTextEffects();
|
|
6950
|
+
return { success: true, effects, count: effects.length };
|
|
5712
6951
|
})();`.trim();
|
|
5713
6952
|
}
|
|
5714
6953
|
}
|
|
@@ -5914,6 +7153,761 @@ ${needWorld}
|
|
|
5914
7153
|
(function() {
|
|
5915
7154
|
if (typeof app.removeWorld3D !== 'function') { return { success: false, error: 'app.removeWorld3D unavailable — update FxTool' }; }
|
|
5916
7155
|
return { success: !!app.removeWorld3D() };
|
|
7156
|
+
})();`.trim();
|
|
7157
|
+
// --- Mesh authoring ---------------------------------------------------
|
|
7158
|
+
// extrudeToMesh / latheToMesh REFUSE by name when there is no world
|
|
7159
|
+
// ({ok:false, reason}), so their reason is passed through rather than
|
|
7160
|
+
// pre-empted with a second wording for the same condition. The facade
|
|
7161
|
+
// check stays, because a studio without the method is a different fault.
|
|
7162
|
+
case 'extrude_path':
|
|
7163
|
+
case 'lathe_path': {
|
|
7164
|
+
const fn = input.action === 'extrude_path' ? 'extrudeToMesh' : 'latheToMesh';
|
|
7165
|
+
const mesh = { ...(input.mesh ?? {}) };
|
|
7166
|
+
// rotY is RADIANS on this path — stored raw and handed to cos()/sin()
|
|
7167
|
+
// in the shader. rotYDegrees is the convenience spelling and is folded
|
|
7168
|
+
// in here so only one of them ever reaches the engine.
|
|
7169
|
+
if (typeof mesh.rotYDegrees === 'number') {
|
|
7170
|
+
if (mesh.rotY === undefined)
|
|
7171
|
+
mesh.rotY = mesh.rotYDegrees * Math.PI / 180;
|
|
7172
|
+
delete mesh.rotYDegrees;
|
|
7173
|
+
}
|
|
7174
|
+
return `
|
|
7175
|
+
// World3D: ${input.action} — a canvas path becomes real geometry
|
|
7176
|
+
(async function() {
|
|
7177
|
+
// @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
|
|
7178
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
|
|
7179
|
+
const r = await app.${fn}(${S(input.pathId)}, ${S(mesh)});
|
|
7180
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the world refused the mesh' }; }
|
|
7181
|
+
return { success: true, meshId: r.id, op: ${S(input.action === 'extrude_path' ? 'extrude' : 'lathe')}, sourceId: ${S(input.pathId)} };
|
|
7182
|
+
})();`.trim();
|
|
7183
|
+
}
|
|
7184
|
+
case 'list_meshes':
|
|
7185
|
+
return `
|
|
7186
|
+
// World3D: every authored mesh on the stage
|
|
7187
|
+
(function() {
|
|
7188
|
+
if (typeof app.listWorldMeshes !== 'function') { return { success: false, error: 'app.listWorldMeshes unavailable — update FxTool' }; }
|
|
7189
|
+
return { success: true, meshes: app.listWorldMeshes() };
|
|
7190
|
+
})();`.trim();
|
|
7191
|
+
case 'remove_mesh':
|
|
7192
|
+
return `
|
|
7193
|
+
// World3D: drop a mesh
|
|
7194
|
+
(function() {
|
|
7195
|
+
if (typeof app.removeWorldMesh !== 'function') { return { success: false, error: 'app.removeWorldMesh unavailable — update FxTool' }; }
|
|
7196
|
+
return { success: !!app.removeWorldMesh(${S(input.meshId)}) };
|
|
7197
|
+
})();`.trim();
|
|
7198
|
+
// --- Lights -----------------------------------------------------------
|
|
7199
|
+
// addWorldLight THROWS when there is no world, so this one is guarded
|
|
7200
|
+
// ahead of the call rather than reading a refusal back.
|
|
7201
|
+
case 'add_light':
|
|
7202
|
+
return `
|
|
7203
|
+
// World3D: add a point light — at most 8, and the ninth is refused by name
|
|
7204
|
+
(function() {
|
|
7205
|
+
if (typeof app.addWorldLight !== 'function') { return { success: false, error: 'app.addWorldLight unavailable — update FxTool' }; }
|
|
7206
|
+
${needWorld}
|
|
7207
|
+
const r = app.addWorldLight(${S(input.light ?? {})});
|
|
7208
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the light' }; }
|
|
7209
|
+
return { success: true, lightId: r.id, light: r.light };
|
|
7210
|
+
})();`.trim();
|
|
7211
|
+
case 'set_light':
|
|
7212
|
+
return `
|
|
7213
|
+
// World3D: edit a light in place
|
|
7214
|
+
(function() {
|
|
7215
|
+
if (typeof app.setWorldLight !== 'function') { return { success: false, error: 'app.setWorldLight unavailable — update FxTool' }; }
|
|
7216
|
+
return { success: !!app.setWorldLight(${S(input.lightId)}, ${S(input.light ?? {})}) };
|
|
7217
|
+
})();`.trim();
|
|
7218
|
+
case 'remove_light':
|
|
7219
|
+
return `
|
|
7220
|
+
// World3D: drop a light
|
|
7221
|
+
(function() {
|
|
7222
|
+
if (typeof app.removeWorldLight !== 'function') { return { success: false, error: 'app.removeWorldLight unavailable — update FxTool' }; }
|
|
7223
|
+
return { success: !!app.removeWorldLight(${S(input.lightId)}) };
|
|
7224
|
+
})();`.trim();
|
|
7225
|
+
case 'list_lights':
|
|
7226
|
+
return `
|
|
7227
|
+
// World3D: every light, with position, colour, intensity and range
|
|
7228
|
+
(function() {
|
|
7229
|
+
if (typeof app.listWorldLights !== 'function') { return { success: false, error: 'app.listWorldLights unavailable — update FxTool' }; }
|
|
7230
|
+
return { success: true, lights: app.listWorldLights() };
|
|
7231
|
+
})();`.trim();
|
|
7232
|
+
// --- Materials --------------------------------------------------------
|
|
7233
|
+
case 'add_material':
|
|
7234
|
+
return `
|
|
7235
|
+
// World3D: a named, SHARED surface — one edit restyles every object using it
|
|
7236
|
+
(function() {
|
|
7237
|
+
if (typeof app.addWorldMaterial !== 'function') { return { success: false, error: 'app.addWorldMaterial unavailable — update FxTool' }; }
|
|
7238
|
+
${needWorld}
|
|
7239
|
+
const r = app.addWorldMaterial(${S(input.material ?? {})});
|
|
7240
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the world refused the material' }; }
|
|
7241
|
+
return { success: true, materialId: r.id, material: r.material };
|
|
7242
|
+
})();`.trim();
|
|
7243
|
+
case 'set_material':
|
|
7244
|
+
return `
|
|
7245
|
+
// World3D: patch a material — every object referencing it changes next frame
|
|
7246
|
+
(function() {
|
|
7247
|
+
if (typeof app.setWorldMaterial !== 'function') { return { success: false, error: 'app.setWorldMaterial unavailable — update FxTool' }; }
|
|
7248
|
+
return { success: !!app.setWorldMaterial(${S(input.materialId)}, ${S(input.material ?? {})}) };
|
|
7249
|
+
})();`.trim();
|
|
7250
|
+
case 'remove_material':
|
|
7251
|
+
return `
|
|
7252
|
+
// World3D: drop a material
|
|
7253
|
+
(function() {
|
|
7254
|
+
if (typeof app.removeWorldMaterial !== 'function') { return { success: false, error: 'app.removeWorldMaterial unavailable — update FxTool' }; }
|
|
7255
|
+
return { success: !!app.removeWorldMaterial(${S(input.materialId)}) };
|
|
7256
|
+
})();`.trim();
|
|
7257
|
+
case 'list_materials':
|
|
7258
|
+
return `
|
|
7259
|
+
// World3D: every material, with the knobs that actually render
|
|
7260
|
+
(function() {
|
|
7261
|
+
if (typeof app.listWorldMaterials !== 'function') { return { success: false, error: 'app.listWorldMaterials unavailable — update FxTool' }; }
|
|
7262
|
+
return { success: true, materials: app.listWorldMaterials() };
|
|
7263
|
+
})();`.trim();
|
|
7264
|
+
// --- Import -----------------------------------------------------------
|
|
7265
|
+
// Both importers THROW when there is no world, so both are guarded ahead.
|
|
7266
|
+
case 'import_obj':
|
|
7267
|
+
case 'import_gltf': {
|
|
7268
|
+
const fn = input.action === 'import_obj' ? 'importOBJToWorld' : 'importGLTFToWorld';
|
|
7269
|
+
return `
|
|
7270
|
+
// World3D: ${input.action}
|
|
7271
|
+
(async function() {
|
|
7272
|
+
// @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
|
|
7273
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
|
|
7274
|
+
${needWorld}
|
|
7275
|
+
const r = await app.${fn}(${S(input.source)}, ${S(input.importOptions ?? {})});
|
|
7276
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the world refused the import' }; }
|
|
7277
|
+
return { success: true, meshIds: r.ids, ...r };
|
|
7278
|
+
})();`.trim();
|
|
7279
|
+
}
|
|
7280
|
+
case 'list_mesh_clips':
|
|
7281
|
+
return `
|
|
7282
|
+
// World3D: the animation clips an imported skinned mesh carries
|
|
7283
|
+
(function() {
|
|
7284
|
+
if (typeof app.listWorldMeshClips !== 'function') { return { success: false, error: 'app.listWorldMeshClips unavailable — update FxTool' }; }
|
|
7285
|
+
${needWorld}
|
|
7286
|
+
return { success: true, clips: app.listWorldMeshClips(${S(input.meshId)}) };
|
|
7287
|
+
})();`.trim();
|
|
7288
|
+
case 'set_mesh_clip':
|
|
7289
|
+
return `
|
|
7290
|
+
// World3D: choose which clip a skinned mesh plays
|
|
7291
|
+
(function() {
|
|
7292
|
+
if (typeof app.setWorldMeshClip !== 'function') { return { success: false, error: 'app.setWorldMeshClip unavailable — update FxTool' }; }
|
|
7293
|
+
${needWorld}
|
|
7294
|
+
return { success: !!app.setWorldMeshClip(${S(input.meshId)}, ${S(input.clip)}${input.crossfade !== undefined ? `, { crossfade: ${S(input.crossfade)} }` : ''}) };
|
|
7295
|
+
})();`.trim();
|
|
7296
|
+
// --- Navigation, picking, camera --------------------------------------
|
|
7297
|
+
// These facades answer `null` — or 0, for groundHeightAt — when there is
|
|
7298
|
+
// NO WORLD, which is indistinguishable from a real answer: ground really
|
|
7299
|
+
// can be at height 0, and a ray really can hit nothing. Guarded ahead so
|
|
7300
|
+
// "there is no world" never arrives disguised as a measurement.
|
|
7301
|
+
case 'set_nav_target':
|
|
7302
|
+
return `
|
|
7303
|
+
// World3D: point the pointer at the 2D canvas or the 3D world
|
|
7304
|
+
(function() {
|
|
7305
|
+
if (typeof app.setWorldNavTarget !== 'function') { return { success: false, error: 'app.setWorldNavTarget unavailable — update FxTool' }; }
|
|
7306
|
+
const r = app.setWorldNavTarget(${S(input.navTarget)});
|
|
7307
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the nav target was refused' }; }
|
|
7308
|
+
return { success: true, navTarget: ${S(input.navTarget)} };
|
|
7309
|
+
})();`.trim();
|
|
7310
|
+
case 'get_nav_target':
|
|
7311
|
+
return `
|
|
7312
|
+
// World3D: which layer the pointer currently drives
|
|
7313
|
+
(function() {
|
|
7314
|
+
if (typeof app.getWorldNavTarget !== 'function') { return { success: false, error: 'app.getWorldNavTarget unavailable — update FxTool' }; }
|
|
7315
|
+
return { success: true, navTarget: app.getWorldNavTarget() };
|
|
7316
|
+
})();`.trim();
|
|
7317
|
+
case 'ground_height':
|
|
7318
|
+
return `
|
|
7319
|
+
// World3D: the terrain height under a world (x, z)
|
|
7320
|
+
(function() {
|
|
7321
|
+
if (typeof app.groundHeightAt !== 'function') { return { success: false, error: 'app.groundHeightAt unavailable — update FxTool' }; }
|
|
7322
|
+
${needWorld}
|
|
7323
|
+
const p = ${S(input.point)};
|
|
7324
|
+
return { success: true, height: app.groundHeightAt(p.x, p.z !== undefined ? p.z : p.y) };
|
|
7325
|
+
})();`.trim();
|
|
7326
|
+
case 'raycast':
|
|
7327
|
+
return `
|
|
7328
|
+
// World3D: cast a ray and report what it hits
|
|
7329
|
+
(function() {
|
|
7330
|
+
if (typeof app.raycastWorld !== 'function') { return { success: false, error: 'app.raycastWorld unavailable — update FxTool' }; }
|
|
7331
|
+
${needWorld}
|
|
7332
|
+
const hit = app.raycastWorld(${S(input.origin)}, ${S(input.direction)}, {});
|
|
7333
|
+
// A null here means MISSED, not "no world" — the guard above already
|
|
7334
|
+
// separated those two, which is the whole reason it is there.
|
|
7335
|
+
return { success: true, hit: hit || null, missed: !hit };
|
|
7336
|
+
})();`.trim();
|
|
7337
|
+
case 'dolly_camera':
|
|
7338
|
+
return `
|
|
7339
|
+
// World3D: dolly the camera — >1 away, <1 closer
|
|
7340
|
+
(function() {
|
|
7341
|
+
if (typeof app.dollyWorldCamera !== 'function') { return { success: false, error: 'app.dollyWorldCamera unavailable — update FxTool' }; }
|
|
7342
|
+
${needWorld}
|
|
7343
|
+
return { success: true, camera: app.dollyWorldCamera(${S(input.multiplier)}) };
|
|
7344
|
+
})();`.trim();
|
|
7345
|
+
case 'pan_camera':
|
|
7346
|
+
return `
|
|
7347
|
+
// World3D: pan the camera across the ground, in SCREEN pixels
|
|
7348
|
+
(function() {
|
|
7349
|
+
if (typeof app.panWorldCamera !== 'function') { return { success: false, error: 'app.panWorldCamera unavailable — update FxTool' }; }
|
|
7350
|
+
${needWorld}
|
|
7351
|
+
return { success: true, camera: app.panWorldCamera(${S(input.dx)}, ${S(input.dy)}) };
|
|
7352
|
+
})();`.trim();
|
|
7353
|
+
case 'world_to_canvas':
|
|
7354
|
+
return `
|
|
7355
|
+
// World3D: project a world point onto the Paper canvas
|
|
7356
|
+
(function() {
|
|
7357
|
+
if (typeof app.worldToCanvas !== 'function') { return { success: false, error: 'app.worldToCanvas unavailable — update FxTool' }; }
|
|
7358
|
+
${needWorld}
|
|
7359
|
+
return { success: true, point: app.worldToCanvas(${S(input.point)}) };
|
|
7360
|
+
})();`.trim();
|
|
7361
|
+
case 'canvas_to_ground':
|
|
7362
|
+
return `
|
|
7363
|
+
// World3D: where a canvas (x, y) lands on the world's ground
|
|
7364
|
+
(function() {
|
|
7365
|
+
if (typeof app.canvasToGround !== 'function') { return { success: false, error: 'app.canvasToGround unavailable — update FxTool' }; }
|
|
7366
|
+
${needWorld}
|
|
7367
|
+
const p = ${S(input.point)};
|
|
7368
|
+
return { success: true, ground: app.canvasToGround(p.x, p.y) };
|
|
7369
|
+
})();`.trim();
|
|
7370
|
+
// --- Physics in the world ---------------------------------------------
|
|
7371
|
+
// addPhysicsBody3D and fireProjectile3D THROW without a world; the rest
|
|
7372
|
+
// return false, [] or a hollow {contacts:[],removed:[]} — each of which
|
|
7373
|
+
// is a plausible real answer. Both shapes are guarded the same way here.
|
|
7374
|
+
case 'add_body':
|
|
7375
|
+
return `
|
|
7376
|
+
// World3D: a body under gravity, colliding with the world
|
|
7377
|
+
(function() {
|
|
7378
|
+
if (typeof app.addPhysicsBody3D !== 'function') { return { success: false, error: 'app.addPhysicsBody3D unavailable — update FxTool' }; }
|
|
7379
|
+
${needWorld}
|
|
7380
|
+
const r = app.addPhysicsBody3D(${S(input.body)});
|
|
7381
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the world refused the body' }; }
|
|
7382
|
+
return { success: true, bodyId: r.id, ...r };
|
|
7383
|
+
})();`.trim();
|
|
7384
|
+
case 'fire_projectile':
|
|
7385
|
+
return `
|
|
7386
|
+
// World3D: fire a projectile
|
|
7387
|
+
(function() {
|
|
7388
|
+
if (typeof app.fireProjectile3D !== 'function') { return { success: false, error: 'app.fireProjectile3D unavailable — update FxTool' }; }
|
|
7389
|
+
${needWorld}
|
|
7390
|
+
const r = app.fireProjectile3D(${S(input.body)});
|
|
7391
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.error || r.reason)) || 'the projectile was refused' }; }
|
|
7392
|
+
return { success: true, ...r };
|
|
7393
|
+
})();`.trim();
|
|
7394
|
+
case 'remove_body':
|
|
7395
|
+
return `
|
|
7396
|
+
// World3D: drop a body
|
|
7397
|
+
(function() {
|
|
7398
|
+
if (typeof app.removePhysicsBody3D !== 'function') { return { success: false, error: 'app.removePhysicsBody3D unavailable — update FxTool' }; }
|
|
7399
|
+
${needWorld}
|
|
7400
|
+
return { success: !!app.removePhysicsBody3D(${S(input.bodyId)}) };
|
|
7401
|
+
})();`.trim();
|
|
7402
|
+
case 'list_bodies':
|
|
7403
|
+
return `
|
|
7404
|
+
// World3D: every physics body
|
|
7405
|
+
(function() {
|
|
7406
|
+
if (typeof app.listPhysicsBodies3D !== 'function') { return { success: false, error: 'app.listPhysicsBodies3D unavailable — update FxTool' }; }
|
|
7407
|
+
${needWorld}
|
|
7408
|
+
return { success: true, bodies: app.listPhysicsBodies3D() };
|
|
7409
|
+
})();`.trim();
|
|
7410
|
+
case 'step_physics':
|
|
7411
|
+
return `
|
|
7412
|
+
// World3D: advance the simulation, and report what touched what
|
|
7413
|
+
(function() {
|
|
7414
|
+
if (typeof app.stepPhysics3D !== 'function') { return { success: false, error: 'app.stepPhysics3D unavailable — update FxTool' }; }
|
|
7415
|
+
${needWorld}
|
|
7416
|
+
// Without a world this answers {contacts:[],removed:[]} — a perfectly
|
|
7417
|
+
// ordinary quiet step. The guard above is what keeps those apart.
|
|
7418
|
+
const r = app.stepPhysics3D(${S(input.dt)});
|
|
7419
|
+
return { success: true, contacts: r && r.contacts, removed: r && r.removed };
|
|
7420
|
+
})();`.trim();
|
|
7421
|
+
case 'impulse':
|
|
7422
|
+
return `
|
|
7423
|
+
// World3D: apply an impulse
|
|
7424
|
+
(function() {
|
|
7425
|
+
if (typeof app.applyPhysicsImpulse3D !== 'function') { return { success: false, error: 'app.applyPhysicsImpulse3D unavailable — update FxTool' }; }
|
|
7426
|
+
${needWorld}
|
|
7427
|
+
return { success: !!app.applyPhysicsImpulse3D(${S(input.bodyId)}, ${S(input.vector)}) };
|
|
7428
|
+
})();`.trim();
|
|
7429
|
+
case 'set_velocity':
|
|
7430
|
+
return `
|
|
7431
|
+
// World3D: set a body's velocity outright
|
|
7432
|
+
(function() {
|
|
7433
|
+
if (typeof app.setPhysicsVelocity3D !== 'function') { return { success: false, error: 'app.setPhysicsVelocity3D unavailable — update FxTool' }; }
|
|
7434
|
+
${needWorld}
|
|
7435
|
+
return { success: !!app.setPhysicsVelocity3D(${S(input.bodyId)}, ${S(input.vector)}) };
|
|
7436
|
+
})();`.trim();
|
|
7437
|
+
case 'line_of_sight':
|
|
7438
|
+
return `
|
|
7439
|
+
// World3D: can one point see another
|
|
7440
|
+
(function() {
|
|
7441
|
+
if (typeof app.worldLineOfSight !== 'function') { return { success: false, error: 'app.worldLineOfSight unavailable — update FxTool' }; }
|
|
7442
|
+
${needWorld}
|
|
7443
|
+
return { success: true, clear: app.worldLineOfSight(${S(input.from)}, ${S(input.to)}) };
|
|
7444
|
+
})();`.trim();
|
|
7445
|
+
case 'set_mesh_instances':
|
|
7446
|
+
return `
|
|
7447
|
+
// World3D: replace a mesh's instance data — six floats each
|
|
7448
|
+
(function() {
|
|
7449
|
+
if (typeof app.setWorldMeshInstances !== 'function') { return { success: false, error: 'app.setWorldMeshInstances unavailable — update FxTool' }; }
|
|
7450
|
+
${needWorld}
|
|
7451
|
+
// x, y, z, scale, rotY (RADIANS, as everywhere on this path), variant.
|
|
7452
|
+
return { success: !!app.setWorldMeshInstances(${S(input.meshId)}, ${S(input.instances)}) };
|
|
7453
|
+
})();`.trim();
|
|
7454
|
+
}
|
|
7455
|
+
}
|
|
7456
|
+
/**
|
|
7457
|
+
* pinepaper_design_system — answered in this server, not in the browser.
|
|
7458
|
+
*
|
|
7459
|
+
* Only `compose` with `draw` produces code at all: the tokens, the easings
|
|
7460
|
+
* and the style list are computed here and returned by the handler, because
|
|
7461
|
+
* a design decision does not need the canvas to be open. When a scene IS
|
|
7462
|
+
* drawn, it arrives as a finished list of create calls — the layout was
|
|
7463
|
+
* already decided by a pure function, so nothing is left for the page to
|
|
7464
|
+
* work out.
|
|
7465
|
+
*/
|
|
7466
|
+
generateDesignCompose(scene, ops, style) {
|
|
7467
|
+
const S = (v) => JSON.stringify(v);
|
|
7468
|
+
return `
|
|
7469
|
+
// Design: compose a ${style} scene — ${ops.length} items, laid out server-side
|
|
7470
|
+
(function() {
|
|
7471
|
+
if (typeof app.create !== 'function') { return { success: false, error: 'app.create unavailable — update FxTool' }; }
|
|
7472
|
+
const size = ${S({ width: scene.width, height: scene.height })};
|
|
7473
|
+
if (typeof app.setCanvasSize === 'function') { try { app.setCanvasSize(size.width, size.height); } catch (_) { /* a fixed canvas is not a failure */ } }
|
|
7474
|
+
${scene.backgroundHex ? `if (typeof app.setBackgroundColor === 'function') { try { app.setBackgroundColor(${S(scene.backgroundHex)}); } catch (_) { /* ditto */ } }` : '// this style paints no background of its own'}
|
|
7475
|
+
const ops = ${S(ops)};
|
|
7476
|
+
const ids = [];
|
|
7477
|
+
const failed = [];
|
|
7478
|
+
for (const op of ops) {
|
|
7479
|
+
const { type, note, name, ...props } = op;
|
|
7480
|
+
try {
|
|
7481
|
+
const item = app.create(type, props);
|
|
7482
|
+
if (item) { ids.push(item.data && item.data.id); } else { failed.push({ name: name, type: type, reason: 'create returned nothing' }); }
|
|
7483
|
+
} catch (e) { failed.push({ name: name, type: type, reason: (e && e.message) || String(e) }); }
|
|
7484
|
+
}
|
|
7485
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
7486
|
+
// A partial composition is reported as one. Half a poster that claims
|
|
7487
|
+
// success is worse than a poster that says which four elements are missing.
|
|
7488
|
+
return { success: failed.length === 0, style: ${S(style)}, itemIds: ids, itemCount: ids.length, failed: failed };
|
|
7489
|
+
})();`.trim();
|
|
7490
|
+
}
|
|
7491
|
+
/**
|
|
7492
|
+
* pinepaper_stick — the vendored stick-figure kit.
|
|
7493
|
+
*
|
|
7494
|
+
* Both facades take one options bag, so the schema's named fields are
|
|
7495
|
+
* reassembled into it. Naming them individually is the point: the kit's
|
|
7496
|
+
* options were discoverable only by reading FxTool's source.
|
|
7497
|
+
*/
|
|
7498
|
+
generateStick(input) {
|
|
7499
|
+
const S = (v) => JSON.stringify(v);
|
|
7500
|
+
const { action, ...rest } = input;
|
|
7501
|
+
const opts = {};
|
|
7502
|
+
for (const [k, v] of Object.entries(rest))
|
|
7503
|
+
if (v !== undefined)
|
|
7504
|
+
opts[k] = v;
|
|
7505
|
+
const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
|
|
7506
|
+
return `
|
|
7507
|
+
// Stick: ${action}
|
|
7508
|
+
(async function() {
|
|
7509
|
+
// @engine-methods stickFigure stickSet
|
|
7510
|
+
if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool (the stick kit is vendored from mcp-cloud)' }; }
|
|
7511
|
+
const r = await app.${fn}(${S(opts)});
|
|
7512
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the stick kit produced nothing' }; }
|
|
7513
|
+
return { success: true, ...r };
|
|
7514
|
+
})();`.trim();
|
|
7515
|
+
}
|
|
7516
|
+
/**
|
|
7517
|
+
* pinepaper_story — prose becomes a scene.
|
|
7518
|
+
*
|
|
7519
|
+
* `distill` deliberately draws nothing. A caller should be able to read what
|
|
7520
|
+
* the distiller made of the text and edit it before a scene is assembled
|
|
7521
|
+
* from it, rather than discovering the reading by looking at the result.
|
|
7522
|
+
*/
|
|
7523
|
+
generateStory(input) {
|
|
7524
|
+
const S = (v) => JSON.stringify(v);
|
|
7525
|
+
const opts = S(input.options ?? {});
|
|
7526
|
+
switch (input.action) {
|
|
7527
|
+
case 'distill':
|
|
7528
|
+
return `
|
|
7529
|
+
// Story: reduce prose to beats, drawing NOTHING
|
|
7530
|
+
(async function() {
|
|
7531
|
+
if (typeof app.distillArticle !== 'function') { return { success: false, error: 'app.distillArticle unavailable — update FxTool' }; }
|
|
7532
|
+
const beats = await app.distillArticle(${S(input.text)});
|
|
7533
|
+
if (!beats) { return { success: false, error: 'the distiller made nothing of that text' }; }
|
|
7534
|
+
return { success: true, beats: beats };
|
|
7535
|
+
})();`.trim();
|
|
7536
|
+
case 'from_text':
|
|
7537
|
+
return `
|
|
7538
|
+
// Story: distill and assemble in one call
|
|
7539
|
+
(async function() {
|
|
7540
|
+
if (typeof app.storyFromText !== 'function') { return { success: false, error: 'app.storyFromText unavailable — update FxTool' }; }
|
|
7541
|
+
const r = await app.storyFromText(${S(input.text)}, ${opts});
|
|
7542
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the story did not assemble' }; }
|
|
7543
|
+
return { success: true, ...r };
|
|
7544
|
+
})();`.trim();
|
|
7545
|
+
case 'apply_spec':
|
|
7546
|
+
return `
|
|
7547
|
+
// Story: assemble a spec you already have
|
|
7548
|
+
(async function() {
|
|
7549
|
+
if (typeof app.applyStorySpec !== 'function') { return { success: false, error: 'app.applyStorySpec unavailable — update FxTool' }; }
|
|
7550
|
+
const r = await app.applyStorySpec(${S(input.spec)}, ${opts});
|
|
7551
|
+
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the spec did not assemble' }; }
|
|
7552
|
+
return { success: true, ...r };
|
|
7553
|
+
})();`.trim();
|
|
7554
|
+
case 'plan_book':
|
|
7555
|
+
return `
|
|
7556
|
+
// Story: lay images out as pages
|
|
7557
|
+
(function() {
|
|
7558
|
+
if (typeof app.planBook !== 'function') { return { success: false, error: 'app.planBook unavailable — update FxTool' }; }
|
|
7559
|
+
const plan = app.planBook(${S(input.images)}, ${opts});
|
|
7560
|
+
if (!plan) { return { success: false, error: 'no plan came back for those images' }; }
|
|
7561
|
+
return { success: true, plan: plan };
|
|
7562
|
+
})();`.trim();
|
|
7563
|
+
}
|
|
7564
|
+
}
|
|
7565
|
+
/**
|
|
7566
|
+
* pinepaper_interchange — Lottie, dotLottie, GLB, BVH, PNG sequence.
|
|
7567
|
+
*
|
|
7568
|
+
* Three of these refuse through `console.warn` and a falsy return, which the
|
|
7569
|
+
* production build strips — exportGLB with no perspective objects and
|
|
7570
|
+
* exportBVH with no rigging system both do exactly that. Over MCP there is no
|
|
7571
|
+
* console to read, so the PRECONDITION is checked here and named, rather than
|
|
7572
|
+
* letting a caller receive nothing and guess why.
|
|
7573
|
+
*/
|
|
7574
|
+
// @engine-methods exportLottie exportDotLottie exportEngine.exportToStore exportEngine.readExport exportEngine.releaseExport
|
|
7575
|
+
generateInterchange(input) {
|
|
7576
|
+
const S = (v) => JSON.stringify(v);
|
|
7577
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7578
|
+
const opts = S(input.options ?? {});
|
|
7579
|
+
switch (input.action) {
|
|
7580
|
+
case 'export_lottie':
|
|
7581
|
+
case 'export_dotlottie': {
|
|
7582
|
+
const fn = input.action === 'export_lottie' ? 'exportLottie' : 'exportDotLottie';
|
|
7583
|
+
return `
|
|
7584
|
+
// Interchange: ${fn}
|
|
7585
|
+
(async function() {
|
|
7586
|
+
${guard(fn)}
|
|
7587
|
+
const out = await app.${fn}(${opts});
|
|
7588
|
+
if (!out) { return { success: false, error: '${fn} produced nothing — the scene may have no animatable content' }; }
|
|
7589
|
+
return { success: true, format: ${S(input.action === 'export_lottie' ? 'lottie' : 'dotlottie')}, data: out };
|
|
7590
|
+
})();`.trim();
|
|
7591
|
+
}
|
|
7592
|
+
case 'import_lottie':
|
|
7593
|
+
return `
|
|
7594
|
+
// Interchange: a Lottie back onto the canvas
|
|
7595
|
+
(async function() {
|
|
7596
|
+
${guard('importLottie')}
|
|
7597
|
+
// importLottie logs and returns falsy when the importer is absent — a
|
|
7598
|
+
// stripped console makes that indistinguishable from an empty animation.
|
|
7599
|
+
const r = await app.importLottie(${S(input.data)}, ${opts});
|
|
7600
|
+
if (!r) { return { success: false, error: 'the Lottie did not import — it may be malformed, or this build has no Lottie importer' }; }
|
|
7601
|
+
return { success: true, imported: r };
|
|
7602
|
+
})();`.trim();
|
|
7603
|
+
case 'export_glb':
|
|
7604
|
+
return `
|
|
7605
|
+
// Interchange: GLB of the perspective 3D objects
|
|
7606
|
+
(async function() {
|
|
7607
|
+
${guard('exportGLB')}
|
|
7608
|
+
// exportGLB warns and bails when nothing perspective exists. The warning is
|
|
7609
|
+
// stripped in production, so the precondition is checked here instead.
|
|
7610
|
+
const has3d = app._initialized && app._initialized.has && app._initialized.has('threeD');
|
|
7611
|
+
if (!has3d) { return { success: false, error: 'no perspective objects to export — create one with createObject3D first' }; }
|
|
7612
|
+
const out = await app.exportGLB(${opts});
|
|
7613
|
+
if (!out) { return { success: false, error: 'GLB export produced nothing' }; }
|
|
7614
|
+
return { success: true, format: 'glb', data: out };
|
|
7615
|
+
})();`.trim();
|
|
7616
|
+
case 'export_bvh':
|
|
7617
|
+
return `
|
|
7618
|
+
// Interchange: BVH of one skeleton's motion
|
|
7619
|
+
(async function() {
|
|
7620
|
+
${guard('exportBVH')}
|
|
7621
|
+
// Same shape as GLB: exportBVH warns and bails without a RiggingSystem.
|
|
7622
|
+
const rigged = app._initialized && app._initialized.has && app._initialized.has('riggingSystem');
|
|
7623
|
+
if (!rigged) { return { success: false, error: 'no rig to export — create a skeleton first (pinepaper_rigging)' }; }
|
|
7624
|
+
const out = await app.exportBVH(${S(input.skeletonId)}, ${opts});
|
|
7625
|
+
if (!out) { return { success: false, error: 'BVH export produced nothing for skeleton ' + ${S(input.skeletonId)} }; }
|
|
7626
|
+
return { success: true, format: 'bvh', skeletonId: ${S(input.skeletonId)}, data: out };
|
|
7627
|
+
})();`.trim();
|
|
7628
|
+
case 'export_png_sequence':
|
|
7629
|
+
return `
|
|
7630
|
+
// Interchange: one PNG per frame
|
|
7631
|
+
(async function() {
|
|
7632
|
+
${guard('exportPNGSequence')}
|
|
7633
|
+
const out = await app.exportPNGSequence(${opts});
|
|
7634
|
+
if (!out) { return { success: false, error: 'PNG sequence produced nothing' }; }
|
|
7635
|
+
return { success: true, format: 'png-sequence', result: out };
|
|
7636
|
+
})();`.trim();
|
|
7637
|
+
}
|
|
7638
|
+
}
|
|
7639
|
+
/**
|
|
7640
|
+
* pinepaper_sound — synthesis, and the drawing that is the same object.
|
|
7641
|
+
*
|
|
7642
|
+
* Every facade here rides `app.audioGraph`, which is lazily built, so each
|
|
7643
|
+
* action guards the METHOD rather than assuming the graph. The canvas half
|
|
7644
|
+
* (`create`, `timbre_from_path`) goes through PinePaper directly.
|
|
7645
|
+
*/
|
|
7646
|
+
generateSound(input) {
|
|
7647
|
+
const S = (v) => JSON.stringify(v);
|
|
7648
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7649
|
+
const simple = (fn, args, key, label) => `
|
|
7650
|
+
// Sound: ${label}
|
|
7651
|
+
(function() {
|
|
7652
|
+
${guard(fn)}
|
|
7653
|
+
return { success: true, ${key}: app.${fn}(${args}) };
|
|
7654
|
+
})();`.trim();
|
|
7655
|
+
switch (input.action) {
|
|
7656
|
+
case 'list_instruments': return simple('listInstruments', '', 'instruments', 'the instrument catalogue');
|
|
7657
|
+
case 'list_percussion': return simple('listPercussion', '', 'percussion', 'the percussion catalogue');
|
|
7658
|
+
case 'list_sfx': return simple('listSfx', '', 'sfx', 'the SFX catalogue');
|
|
7659
|
+
case 'play_tone': return simple('playTone', `${S(input.note)}, ${S(input.options ?? {})}`, 'played', `tone ${input.note}`);
|
|
7660
|
+
case 'play_chord': return simple('playChord', `${S(input.root)}, ${S(input.chord)}, ${S(input.options ?? {})}`, 'played', `${input.chord} chord on ${input.root}`);
|
|
7661
|
+
case 'chord_frequencies': return simple('chordFrequencies', `${S(input.root)}, ${S(input.chord)}, ${S(input.options ?? {})}`, 'frequencies', `the Hz of ${input.chord} on ${input.root}`);
|
|
7662
|
+
case 'play_percussion': return simple('playPercussion', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `percussion ${input.name}`);
|
|
7663
|
+
case 'play_sfx': return simple('playSfx', `${S(input.name)}, ${S(input.options ?? {})}`, 'played', `sfx ${input.name}`);
|
|
7664
|
+
case 'play_spec': return simple('playSound', S(input.spec), 'played', 'a raw spec');
|
|
7665
|
+
case 'play_from_text': return simple('playSoundFromText', S(input.text), 'played', `"${input.text}"`);
|
|
7666
|
+
case 'from_text':
|
|
7667
|
+
return `
|
|
7668
|
+
// Sound: resolve "${input.text}" to a spec WITHOUT playing it
|
|
7669
|
+
(function() {
|
|
7670
|
+
${guard('buildSoundFromText')}
|
|
7671
|
+
const spec = app.buildSoundFromText(${S(input.text)});
|
|
7672
|
+
// A description the resolver cannot read comes back empty rather than
|
|
7673
|
+
// throwing. Saying so beats handing back a spec that plays silence.
|
|
7674
|
+
if (!spec) { return { success: false, error: 'could not resolve ' + ${S(input.text)} + ' into a sound' }; }
|
|
7675
|
+
return { success: true, spec: spec };
|
|
7676
|
+
})();`.trim();
|
|
7677
|
+
// DEFINING A SOUND. The engine's three tables are "a curated starting set,
|
|
7678
|
+
// not a claim that music contains six instruments" — its words. Anything
|
|
7679
|
+
// it does not model can be registered at runtime and is then first-class:
|
|
7680
|
+
// listInstruments() reads the LIVE table, so a defined instrument shows
|
|
7681
|
+
// up in list_instruments and plays by name like any built-in.
|
|
7682
|
+
//
|
|
7683
|
+
// The canonical name is RETURNED rather than assumed. Registration
|
|
7684
|
+
// normalises to [a-z][a-z0-9_-]{0,31}, so a model that registers
|
|
7685
|
+
// 'Rhodes' and then plays 'Rhodes' is playing something that does not
|
|
7686
|
+
// exist — the name it actually got is 'rhodes', and it needs to be told.
|
|
7687
|
+
case 'define_instrument':
|
|
7688
|
+
case 'define_percussion':
|
|
7689
|
+
case 'define_sfx': {
|
|
7690
|
+
const fn = input.action === 'define_instrument' ? 'registerInstrument'
|
|
7691
|
+
: input.action === 'define_percussion' ? 'registerPercussion' : 'registerSfx';
|
|
7692
|
+
const listFn = input.action === 'define_instrument' ? 'listInstruments'
|
|
7693
|
+
: input.action === 'define_percussion' ? 'listPercussion' : 'listSfx';
|
|
7694
|
+
const spec = {
|
|
7695
|
+
...(input.partials ? { partials: input.partials } : {}),
|
|
7696
|
+
...(input.envelope ? { envelope: input.envelope } : {}),
|
|
7697
|
+
...(input.gain !== undefined ? { gain: input.gain } : {}),
|
|
7698
|
+
...(input.aliases ? { aliases: input.aliases } : {}),
|
|
7699
|
+
...(input.noise !== undefined ? { noise: input.noise } : {}),
|
|
7700
|
+
...(input.noiseFreq !== undefined ? { noiseFreq: input.noiseFreq } : {}),
|
|
7701
|
+
...(input.hz !== undefined ? { hz: input.hz } : {}),
|
|
7702
|
+
...(input.pitch ? { pitch: input.pitch } : {}),
|
|
7703
|
+
};
|
|
7704
|
+
// @engine-methods registerInstrument registerPercussion registerSfx
|
|
7705
|
+
// The guard below is INTERPOLATED — `guard(fn)` takes a variable, not a
|
|
7706
|
+
// literal — so sync-engine-methods.mjs cannot read the names out of it
|
|
7707
|
+
// and the studio probe would report a studio as complete while never
|
|
7708
|
+
// checking the method a caller needs. It extracted only
|
|
7709
|
+
// renderSoundtrackWav until this line existed.
|
|
7710
|
+
return `
|
|
7711
|
+
// Sound: define ${input.name} at runtime
|
|
7712
|
+
(function() {
|
|
7713
|
+
${guard(fn)}
|
|
7714
|
+
var name;
|
|
7715
|
+
// The engine THROWS on an unusable name or a silent spec, with a message
|
|
7716
|
+
// naming what was wrong. That is the useful answer, so it is caught and
|
|
7717
|
+
// returned rather than allowed to surface as a bridge error with no subject.
|
|
7718
|
+
try { name = app.${fn}(${S(input.name)}, ${S(spec)}); }
|
|
7719
|
+
catch (e) { return { success: false, error: (e && e.message) || String(e) }; }
|
|
7720
|
+
var listed = typeof app.${listFn} === 'function' ? app.${listFn}() : null;
|
|
7721
|
+
var found = Array.isArray(listed)
|
|
7722
|
+
? listed.some(function (x) { return (x && x.name) === name; })
|
|
7723
|
+
: null;
|
|
7724
|
+
return {
|
|
7725
|
+
success: true,
|
|
7726
|
+
// The name the engine CHOSE, which is what plays. Registering 'Rhodes'
|
|
7727
|
+
// returns 'rhodes'.
|
|
7728
|
+
canonicalName: name,
|
|
7729
|
+
requestedName: ${S(input.name)},
|
|
7730
|
+
normalised: name !== ${S(input.name)},
|
|
7731
|
+
// Defined but unlistable would be a capability nobody can name. Proven per
|
|
7732
|
+
// call rather than assumed, because it is one table lookup.
|
|
7733
|
+
listed: found,
|
|
7734
|
+
};
|
|
7735
|
+
})();`.trim();
|
|
7736
|
+
}
|
|
7737
|
+
case 'render_soundtrack': {
|
|
7738
|
+
const opts = {
|
|
7739
|
+
...(typeof input.options?.duration === 'number' ? { duration: input.options.duration } : {}),
|
|
7740
|
+
...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}),
|
|
7741
|
+
...(input.bitDepth !== undefined ? { bitDepth: input.bitDepth } : {}),
|
|
7742
|
+
};
|
|
7743
|
+
return `
|
|
7744
|
+
// Sound: mix every placed sound to a WAV, offline
|
|
7745
|
+
(function() {
|
|
7746
|
+
${guard('renderSoundtrackWav')}
|
|
7747
|
+
var r = app.renderSoundtrackWav(${S(opts)});
|
|
7748
|
+
// BARE NULL MEANS "NO SOUNDS PLACED". Returned for an empty scene and for a
|
|
7749
|
+
// scene whose sounds all failed to mix — indistinguishable from a malfunction
|
|
7750
|
+
// unless it is named, and "the export produced nothing" is the report that
|
|
7751
|
+
// sends someone debugging the exporter instead of placing a sound.
|
|
7752
|
+
if (!r || !r.wav) {
|
|
7753
|
+
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".' };
|
|
7754
|
+
}
|
|
7755
|
+
var bytes = r.wav;
|
|
7756
|
+
// Uint8Array cannot cross the bridge; base64 in chunks, because
|
|
7757
|
+
// String.fromCharCode.apply over a multi-megabyte array blows the stack.
|
|
7758
|
+
var bin = '';
|
|
7759
|
+
for (var i = 0; i < bytes.length; i += 8192) {
|
|
7760
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
7761
|
+
}
|
|
7762
|
+
return {
|
|
7763
|
+
success: true,
|
|
7764
|
+
wavBase64: btoa(bin),
|
|
7765
|
+
byteLength: bytes.length,
|
|
7766
|
+
duration: r.duration,
|
|
7767
|
+
sampleRate: r.sampleRate,
|
|
7768
|
+
sounds: r.sounds,
|
|
7769
|
+
placed: r.placed,
|
|
7770
|
+
// DROPPED IS THE SECOND SILENT REFUSAL. A mix with placed > 0 and dropped
|
|
7771
|
+
// > 0 is a valid WAV that is quietly missing sounds — the same shape as an
|
|
7772
|
+
// item list capped below its own itemCount. Reported only when it happened,
|
|
7773
|
+
// so the marker stays meaningful.
|
|
7774
|
+
...(r.dropped > 0 ? { dropped: r.dropped, incomplete:
|
|
7775
|
+
'mixed ' + r.placed + ' sound(s); ' + r.dropped + ' could not be rendered and are absent from this file' } : {}),
|
|
7776
|
+
};
|
|
7777
|
+
})();`.trim();
|
|
7778
|
+
}
|
|
7779
|
+
case 'create':
|
|
7780
|
+
return `
|
|
7781
|
+
// Sound: draw it AS a waveform path — the item and the sound are one thing
|
|
7782
|
+
(function() {
|
|
7783
|
+
${guard('createSound')}
|
|
7784
|
+
const item = app.createSound(${S(input.spec ?? {})}, ${S(input.visual ?? {})});
|
|
7785
|
+
if (!item) { return { success: false, error: 'the sound produced no waveform path' }; }
|
|
7786
|
+
return { success: true, itemId: item.data && item.data.id, itemType: 'sound' };
|
|
7787
|
+
})();`.trim();
|
|
7788
|
+
case 'timbre_from_path':
|
|
7789
|
+
return `
|
|
7790
|
+
// Sound: read a drawn path's shape as harmonic content
|
|
7791
|
+
(function() {
|
|
7792
|
+
${guard('timbreFromPath')}
|
|
7793
|
+
const item = app.getItemById ? app.getItemById(${S(input.itemId)}) : null;
|
|
7794
|
+
// timbreFromPath answers a flat {partials:[{h:1,amp:1}]} for anything that is
|
|
7795
|
+
// not a path with length — a real timbre and a refusal look identical, so the
|
|
7796
|
+
// item is checked HERE instead of reading that back as an answer.
|
|
7797
|
+
if (!item) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
7798
|
+
if (typeof item.getPointAt !== 'function' || !(item.length > 0)) {
|
|
7799
|
+
return { success: false, error: 'timbre needs a path with length — ' + ${S(input.itemId)} + ' is not one' };
|
|
7800
|
+
}
|
|
7801
|
+
return { success: true, timbre: app.timbreFromPath(item, ${S(input.samples ?? 256)}) };
|
|
7802
|
+
})();`.trim();
|
|
7803
|
+
case 'set_placement': return simple('setSoundPlacement', `${S(input.itemId)}, ${S(input.placement ?? {})}`, 'placed', 'placement');
|
|
7804
|
+
case 'remove': return simple('removeSound', S(input.itemId), 'removed', 'remove');
|
|
7805
|
+
case 'stop_all': return simple('stopSounds', '', 'stopped', 'stop everything');
|
|
7806
|
+
}
|
|
7807
|
+
}
|
|
7808
|
+
/**
|
|
7809
|
+
* pinepaper_motion — the generators' Animation knob for any group.
|
|
7810
|
+
*
|
|
7811
|
+
* animateItems returns `{error}` on failure, NOT `{ok:false, reason}` like
|
|
7812
|
+
* the path facades — reading the wrong key here turns "could not attach
|
|
7813
|
+
* \"ripple\" (frame-callback limit reached?)" into undefined.
|
|
7814
|
+
*/
|
|
7815
|
+
generateMotion(input) {
|
|
7816
|
+
const S = (v) => JSON.stringify(v);
|
|
7817
|
+
if (input.action === 'list') {
|
|
7818
|
+
return `
|
|
7819
|
+
// The engine's own motion catalogue — motions, waveforms, origins, knobs
|
|
7820
|
+
(async function() {
|
|
7821
|
+
if (typeof app.listGeneratorMotions !== 'function') { return { success: false, error: 'app.listGeneratorMotions unavailable — update FxTool' }; }
|
|
7822
|
+
const c = await app.listGeneratorMotions();
|
|
7823
|
+
return { success: true, ...c };
|
|
7824
|
+
})();`.trim();
|
|
7825
|
+
}
|
|
7826
|
+
const target = input.itemIds && input.itemIds.length ? S(input.itemIds) : S(input.itemId);
|
|
7827
|
+
const opts = { animation: input.motion };
|
|
7828
|
+
for (const k of ['speed', 'intensity', 'waveform', 'origin', 'seed']) {
|
|
7829
|
+
if (input[k] !== undefined)
|
|
7830
|
+
opts[k] = input[k];
|
|
7831
|
+
}
|
|
7832
|
+
return `
|
|
7833
|
+
// Motion: ${input.motion} on ${input.itemIds?.length ? `${input.itemIds.length} items` : input.itemId}
|
|
7834
|
+
(async function() {
|
|
7835
|
+
if (typeof app.animateItems !== 'function') { return { success: false, error: 'app.animateItems unavailable — update FxTool' }; }
|
|
7836
|
+
const r = await app.animateItems(${target}, ${S(opts)});
|
|
7837
|
+
// An unknown motion comes back with the known list attached, so a caller can
|
|
7838
|
+
// correct itself without a second round trip.
|
|
7839
|
+
if (!r || r.error) { return { success: false, error: (r && r.error) || 'the motion did not attach', motions: r && r.motions }; }
|
|
7840
|
+
return { success: true, motion: ${S(input.motion)} };
|
|
7841
|
+
})();`.trim();
|
|
7842
|
+
}
|
|
7843
|
+
/**
|
|
7844
|
+
* pinepaper_path — the destructive path operations.
|
|
7845
|
+
*
|
|
7846
|
+
* Every facade here returns `{ok:false, reason}` and names its own refusal
|
|
7847
|
+
* ("a boolean needs at least two paths", "that path has no stroke to
|
|
7848
|
+
* outline"), so the reason is passed through rather than reworded. A second
|
|
7849
|
+
* wording for one condition is the thing to avoid, not the guard.
|
|
7850
|
+
*/
|
|
7851
|
+
generatePathOp(input) {
|
|
7852
|
+
const S = (v) => JSON.stringify(v);
|
|
7853
|
+
const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
|
|
7854
|
+
const pass = (expr) => `
|
|
7855
|
+
const r = ${expr};
|
|
7856
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.reason) || 'the engine refused the operation' }; }
|
|
7857
|
+
return { success: true, ...r };`;
|
|
7858
|
+
switch (input.action) {
|
|
7859
|
+
case 'boolean':
|
|
7860
|
+
return `
|
|
7861
|
+
// Path boolean: ${input.op} — CONSUMES its operands and leaves one result
|
|
7862
|
+
(function() {
|
|
7863
|
+
${guard('booleanOp')}${pass(`app.booleanOp(${S(input.itemIds)}, ${S(input.op)})`)}
|
|
7864
|
+
})();`.trim();
|
|
7865
|
+
case 'simplify':
|
|
7866
|
+
return `
|
|
7867
|
+
// Simplify: fewer segments, same shape
|
|
7868
|
+
(function() {
|
|
7869
|
+
${guard('simplifyPath')}${pass(`app.simplifyPath(${S(input.itemId)}${input.tolerance !== undefined ? `, ${S(input.tolerance)}` : ''})`)}
|
|
7870
|
+
})();`.trim();
|
|
7871
|
+
case 'outline_stroke':
|
|
7872
|
+
return `
|
|
7873
|
+
// Outline stroke: a stroked line becomes a filled shape of the same width
|
|
7874
|
+
(function() {
|
|
7875
|
+
${guard('outlineStroke')}${pass(`app.outlineStroke(${S(input.itemId)}, ${S(input.tolerance !== undefined ? { tolerance: input.tolerance } : {})})`)}
|
|
7876
|
+
})();`.trim();
|
|
7877
|
+
case 'toggle_closed':
|
|
7878
|
+
return `
|
|
7879
|
+
// Open or close the path
|
|
7880
|
+
(function() {
|
|
7881
|
+
${guard('togglePathClosed')}${pass(`app.togglePathClosed(${S(input.itemId)})`)}
|
|
7882
|
+
})();`.trim();
|
|
7883
|
+
case 'pattern':
|
|
7884
|
+
return `
|
|
7885
|
+
// Pattern: repeat the item — ${input.kind}
|
|
7886
|
+
(function() {
|
|
7887
|
+
${guard('patternFrom')}${pass(`app.patternFrom(${S(input.itemId)}, ${S(input.kind)}, ${S(input.pattern ?? {})})`)}
|
|
7888
|
+
})();`.trim();
|
|
7889
|
+
case 'get_geometry':
|
|
7890
|
+
return `
|
|
7891
|
+
// Read the path's segments back
|
|
7892
|
+
(function() {
|
|
7893
|
+
${guard('getPathGeometry')}
|
|
7894
|
+
const g = app.getPathGeometry(${S(input.itemId)}, {});
|
|
7895
|
+
// null means no such item — the one facade here that does NOT name its
|
|
7896
|
+
// refusal, so this is the one place a wording is supplied rather than passed.
|
|
7897
|
+
if (!g) { return { success: false, error: 'no such item: ' + ${S(input.itemId)} }; }
|
|
7898
|
+
return { success: true, geometry: g };
|
|
7899
|
+
})();`.trim();
|
|
7900
|
+
case 'set_locked':
|
|
7901
|
+
return `
|
|
7902
|
+
// Lock or unlock — a locked item is also unselectable and undraggable
|
|
7903
|
+
(function() {
|
|
7904
|
+
${guard('setItemLocked')}${pass(`app.setItemLocked(${S(input.itemId)}, ${S(input.locked)})`)}
|
|
7905
|
+
})();`.trim();
|
|
7906
|
+
case 'unlock_all':
|
|
7907
|
+
return `
|
|
7908
|
+
// Unlock everything on the canvas
|
|
7909
|
+
(function() {
|
|
7910
|
+
${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
|
|
5917
7911
|
})();`.trim();
|
|
5918
7912
|
}
|
|
5919
7913
|
}
|
|
@@ -5966,7 +7960,8 @@ ${needWorld}
|
|
|
5966
7960
|
const root = input.rootPosition ? S(input.rootPosition) : S({ x: 400, y: 300 });
|
|
5967
7961
|
return wrap('Rigging: create skeleton', ` const skeletonId = R.createSkeleton(${S(input.name ?? null)}, ${root});
|
|
5968
7962
|
if (app.historyManager) app.historyManager.saveState();
|
|
5969
|
-
return { success:
|
|
7963
|
+
if (!skeletonId) { return { success: false, action: 'create_skeleton', error: "the engine returned no skeleton id — createSkeleton does not normally fail, so treat this as a broken rigging subsystem rather than bad input" }; }
|
|
7964
|
+
return { success: true, action: 'create_skeleton', skeletonId: skeletonId };`);
|
|
5970
7965
|
}
|
|
5971
7966
|
case 'add_bone': {
|
|
5972
7967
|
const config = S({
|
|
@@ -5979,13 +7974,15 @@ ${needWorld}
|
|
|
5979
7974
|
});
|
|
5980
7975
|
return wrap('Rigging: add bone', ` const boneId = R.addBone(${S(input.skeletonId)}, ${config});
|
|
5981
7976
|
if (app.historyManager) app.historyManager.saveState();
|
|
5982
|
-
return { success:
|
|
7977
|
+
if (!boneId) { return { success: false, action: 'add_bone', error: "no such skeleton. Call list_skeletons for the ids that exist" }; }
|
|
7978
|
+
return { success: true, action: 'add_bone', boneId: boneId };`);
|
|
5983
7979
|
}
|
|
5984
7980
|
case 'attach_item': {
|
|
5985
7981
|
const opts = S(input.attachPoint !== undefined ? { attachPoint: input.attachPoint } : {});
|
|
5986
7982
|
return wrap('Rigging: attach item to bone', ` const ok = R.attachItem(${S(input.skeletonId)}, ${S(input.boneId)}, ${S(input.itemId)}, ${opts});
|
|
5987
7983
|
if (app.historyManager) app.historyManager.saveState();
|
|
5988
|
-
return { success:
|
|
7984
|
+
if (!ok) { return { success: false, action: 'attach_item', error: "no such skeleton, or no such bone on it. list_bones gives the bone ids for a skeleton" }; }
|
|
7985
|
+
return { success: true, action: 'attach_item', itemId: ${S(input.itemId)}, boneId: ${S(input.boneId)} };`);
|
|
5989
7986
|
}
|
|
5990
7987
|
case 'create_ik_chain': {
|
|
5991
7988
|
const config = S({
|
|
@@ -5999,7 +7996,8 @@ ${needWorld}
|
|
|
5999
7996
|
});
|
|
6000
7997
|
return wrap('Rigging: create IK chain', ` const chainId = R.createIKChain(${S(input.skeletonId)}, ${config});
|
|
6001
7998
|
if (app.historyManager) app.historyManager.saveState();
|
|
6002
|
-
return { success:
|
|
7999
|
+
if (!chainId) { return { success: false, action: 'create_ik_chain', error: "no such skeleton, or fewer than 2 boneIds — a chain needs at least two bones to solve. Call list_bones for the ids on this rig" }; }
|
|
8000
|
+
return { success: true, action: 'create_ik_chain', chainId: chainId };`);
|
|
6003
8001
|
}
|
|
6004
8002
|
case 'add_pose_keyframe': {
|
|
6005
8003
|
const opts = S({
|
|
@@ -6012,7 +8010,8 @@ ${needWorld}
|
|
|
6012
8010
|
});
|
|
6013
8011
|
return wrap('Rigging: add pose keyframe', ` const ok = R.addPoseKeyframe(${S(input.skeletonId)}, ${input.time}, ${S(input.pose)}, ${S(input.easing ?? 'linear')}, ${opts});
|
|
6014
8012
|
if (app.historyManager) app.historyManager.saveState();
|
|
6015
|
-
return { success:
|
|
8013
|
+
if (!ok) { return { success: false, action: 'add_pose_keyframe', error: "no such skeleton, or 'pose' named a saved pose that does not exist. Pass an inline { boneId: angleDeg } map instead, or list_poses for the saved ones" }; }
|
|
8014
|
+
return { success: true, action: 'add_pose_keyframe', time: ${input.time} };`);
|
|
6016
8015
|
}
|
|
6017
8016
|
case 'set_target_path': {
|
|
6018
8017
|
const opts = S({
|
|
@@ -6021,16 +8020,170 @@ ${needWorld}
|
|
|
6021
8020
|
});
|
|
6022
8021
|
return wrap('Rigging: set IK target path', ` const ok = R.setTargetPath(${S(input.skeletonId)}, ${S(input.chainId)}, ${S(input.waypoints)}, ${opts});
|
|
6023
8022
|
if (app.historyManager) app.historyManager.saveState();
|
|
6024
|
-
return { success:
|
|
8023
|
+
if (!ok) { return { success: false, action: 'set_target_path', error: "no such skeleton, no such IK chain on it, or waypoints was not an array. create_ik_chain returns the chainId to use here" }; }
|
|
8024
|
+
return { success: true, action: 'set_target_path', chainId: ${S(input.chainId)} };`);
|
|
6025
8025
|
}
|
|
6026
8026
|
case 'save_pose':
|
|
6027
8027
|
return wrap('Rigging: save pose', ` const poseId = R.savePose(${S(input.skeletonId)}, ${S(input.name ?? null)});
|
|
6028
8028
|
if (app.historyManager) app.historyManager.saveState();
|
|
6029
|
-
return { success:
|
|
8029
|
+
if (!poseId) { return { success: false, action: 'save_pose', error: "no such skeleton. A pose is a snapshot of ITS bone angles, so the skeleton has to exist first — call list_skeletons" }; }
|
|
8030
|
+
return { success: true, action: 'save_pose', poseId: poseId };`);
|
|
6030
8031
|
case 'save_shape_key':
|
|
6031
8032
|
return wrap('Rigging: save shape key', ` const shapeKeyId = R.saveShapeKey(${S(input.skeletonId)}, ${S(input.name ?? null)});
|
|
6032
8033
|
if (app.historyManager) app.historyManager.saveState();
|
|
6033
|
-
return { success:
|
|
8034
|
+
if (!shapeKeyId) { return { success: false, action: 'save_shape_key', error: "no such skeleton, or nothing is attached to its bones — a shape key snapshots the attached items, so a bare skeleton has nothing to capture" }; }
|
|
8035
|
+
return { success: true, action: 'save_shape_key', shapeKeyId: shapeKeyId };`);
|
|
8036
|
+
// ── Pose motion. Every case below maps 1:1 to a method that exists on
|
|
8037
|
+
// riggingSystem (or, for stitch_poses, a PinePaper facade) — checked
|
|
8038
|
+
// against the engine source, not against the docs, because the docs have
|
|
8039
|
+
// named tools that were never registered before now.
|
|
8040
|
+
case 'list_skeletons':
|
|
8041
|
+
return wrap('Rigging: list skeletons', ` const skeletons = R.listSkeletons();
|
|
8042
|
+
return { success: true, action: 'list_skeletons', skeletons: skeletons, count: skeletons.length };`);
|
|
8043
|
+
case 'list_bones':
|
|
8044
|
+
// Without this an inline pose is unwritable: a pose is
|
|
8045
|
+
// { boneId: angleDeg } and nothing else returns a bone id.
|
|
8046
|
+
return wrap('Rigging: list bones', ` if (typeof R.listBones !== 'function') { return { success: false, error: 'listBones unavailable — update FxTool' }; }
|
|
8047
|
+
const bones = R.listBones(${S(input.skeletonId)});
|
|
8048
|
+
return { success: true, action: 'list_bones', bones: bones, count: bones.length };`);
|
|
8049
|
+
case 'list_pose_libraries':
|
|
8050
|
+
return wrap('Rigging: list pose libraries', ` return (async function() {
|
|
8051
|
+
if (typeof R.ensurePoseLibraries !== 'function') { return { success: false, error: 'pose libraries unavailable — update FxTool' }; }
|
|
8052
|
+
const names = await R.ensurePoseLibraries();
|
|
8053
|
+
return { success: true, action: 'list_pose_libraries', libraries: names.libraries, transitions: names.transitions };
|
|
8054
|
+
})();`);
|
|
8055
|
+
case 'load_pose_library': {
|
|
8056
|
+
// The libraries used to load only from an editor button, so every
|
|
8057
|
+
// agent ran with none and got null from every preset name. Awaited
|
|
8058
|
+
// here so the guarantee is real rather than dependent on whether a
|
|
8059
|
+
// human happened to click something earlier in the session.
|
|
8060
|
+
const map = input.boneMap ? S(input.boneMap) : 'null';
|
|
8061
|
+
return wrap('Rigging: load a stock pose library', ` return (async function() {
|
|
8062
|
+
if (typeof R.ensurePoseLibraries !== 'function') { return { success: false, error: 'pose libraries unavailable — update FxTool' }; }
|
|
8063
|
+
const avail = await R.ensurePoseLibraries();
|
|
8064
|
+
let boneMap = ${map};
|
|
8065
|
+
if (!boneMap) {
|
|
8066
|
+
boneMap = {};
|
|
8067
|
+
for (const b of (R.listBones ? R.listBones(${S(input.skeletonId)}) : [])) { if (b.name) boneMap[b.name] = b.id; }
|
|
8068
|
+
}
|
|
8069
|
+
const res = R.loadPoseLibrary(${S(input.skeletonId)}, ${S(input.libraryName)}, boneMap);
|
|
8070
|
+
if (!res) { return { success: false, action: 'load_pose_library', error: 'no such library, or none of its bone names matched this rig. Available: ' + avail.libraries.join(', '), available: avail.libraries }; }
|
|
8071
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
8072
|
+
return { success: true, action: 'load_pose_library', library: ${S(input.libraryName)}, poses: res };
|
|
8073
|
+
})();`);
|
|
8074
|
+
}
|
|
8075
|
+
case 'list_poses':
|
|
8076
|
+
return wrap('Rigging: list poses', ` const poses = R.listPoses(${S(input.skeletonId ?? null)});
|
|
8077
|
+
return { success: true, action: 'list_poses', poses: poses, count: poses.length };`);
|
|
8078
|
+
case 'load_pose':
|
|
8079
|
+
return wrap('Rigging: load pose', ` const ok = R.loadPose(${S(input.poseId)});
|
|
8080
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
8081
|
+
if (!ok) { return { success: false, action: 'load_pose', error: "no such pose, or the skeleton it was saved against is gone. list_poses for what is still loadable" }; }
|
|
8082
|
+
return { success: true, action: 'load_pose', poseId: ${S(input.poseId)} };`);
|
|
8083
|
+
case 'interpolate_poses':
|
|
8084
|
+
return wrap('Rigging: interpolate poses', ` const ok = R.interpolatePoses(${S(input.poseIdA)}, ${S(input.poseIdB)}, ${input.t ?? 0.5});
|
|
8085
|
+
if (!ok) { return { success: false, action: 'interpolate_poses', error: "one of the two poses does not exist, or they belong to DIFFERENT skeletons — blending across rigs is not defined. Call list_poses to see which skeleton each belongs to" }; }
|
|
8086
|
+
return { success: true, action: 'interpolate_poses', t: ${input.t ?? 0.5} };`);
|
|
8087
|
+
case 'play_pose_sequence': {
|
|
8088
|
+
const opts = S({
|
|
8089
|
+
...(input.loop !== undefined ? { loop: input.loop } : {}),
|
|
8090
|
+
...(input.options ?? {}),
|
|
8091
|
+
});
|
|
8092
|
+
return wrap('Rigging: play pose sequence', ` const ok = R.playPoseSequence(${S(input.skeletonId)}, ${S(input.sequence ?? [])}, ${opts});
|
|
8093
|
+
if (!ok) { return { success: false, action: 'play_pose_sequence', error: "no such skeleton, or the sequence was empty. Each key needs a 'pose' id or an inline 'angles' map — call list_poses for the saved ids" }; }
|
|
8094
|
+
return { success: true, action: 'play_pose_sequence', keys: ${(input.sequence ?? []).length} };`);
|
|
8095
|
+
}
|
|
8096
|
+
case 'stop_pose_sequence':
|
|
8097
|
+
return wrap('Rigging: stop pose sequence', ` const ok = R.stopPoseSequence(${S(input.skeletonId)});
|
|
8098
|
+
if (!ok) { return { success: false, action: 'stop_pose_sequence', error: "nothing was playing on that skeleton — this is a no-op rather than a failure" }; }
|
|
8099
|
+
return { success: true, action: 'stop_pose_sequence' };`);
|
|
8100
|
+
case 'stitch_poses': {
|
|
8101
|
+
// The one action on `app` rather than riggingSystem: stitching resolves
|
|
8102
|
+
// saved pose ids and then installs the merged sequence through the
|
|
8103
|
+
// ordinary player, so there is one code path and one thing to scrub.
|
|
8104
|
+
const opts = S({
|
|
8105
|
+
...(input.blend !== undefined ? { blend: input.blend } : {}),
|
|
8106
|
+
...(input.blendSteps !== undefined ? { blendSteps: input.blendSteps } : {}),
|
|
8107
|
+
...(input.matchPhase !== undefined ? { matchPhase: input.matchPhase } : {}),
|
|
8108
|
+
...(input.loop !== undefined ? { loop: input.loop } : {}),
|
|
8109
|
+
...(input.plan !== undefined ? { plan: input.plan } : {}),
|
|
8110
|
+
...(input.options ?? {}),
|
|
8111
|
+
});
|
|
8112
|
+
return wrap('Rigging: stitch pose clips', ` if (typeof app.stitchPoses !== 'function') { return { success: false, error: 'app.stitchPoses unavailable — update FxTool' }; }
|
|
8113
|
+
const res = app.stitchPoses(${S(input.skeletonId)}, ${S(input.clips ?? [])}, ${opts});
|
|
8114
|
+
return Object.assign({ success: !!res.ok, action: 'stitch_poses' }, res);`);
|
|
8115
|
+
}
|
|
8116
|
+
case 'apply_pose_transition':
|
|
8117
|
+
return wrap('Rigging: apply pose transition', ` return (async function() {
|
|
8118
|
+
if (typeof R.ensurePoseLibraries === 'function') { await R.ensurePoseLibraries(); }
|
|
8119
|
+
// A transition names POSES BY NAME, so it needs name → id. Requiring the
|
|
8120
|
+
// caller to build that by hand from list_poses is busywork with one
|
|
8121
|
+
// correct answer, and getting it wrong returns a bare null. Build it here
|
|
8122
|
+
// when it is omitted, exactly as load_pose_library builds its bone map.
|
|
8123
|
+
let poseIdMap = ${input.poseIdMap ? S(input.poseIdMap) : 'null'};
|
|
8124
|
+
if (!poseIdMap || !Object.keys(poseIdMap).length) {
|
|
8125
|
+
poseIdMap = {};
|
|
8126
|
+
for (const pose of (R.listPoses ? R.listPoses(${S(input.skeletonId)}) : [])) { if (pose.name) poseIdMap[pose.name] = pose.id; }
|
|
8127
|
+
}
|
|
8128
|
+
const res = R.applyPoseTransition(${S(input.skeletonId)}, ${S(input.transitionName)}, poseIdMap);
|
|
8129
|
+
if (!res) { return { success: false, action: 'apply_pose_transition', error: "no such transition, or none of its pose names are saved on this skeleton. Load the matching library first (load_pose_library), then retry — the transition plays poses BY NAME." }; }
|
|
8130
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
8131
|
+
return { success: true, action: 'apply_pose_transition', transition: ${S(input.transitionName)} };
|
|
8132
|
+
})();`);
|
|
8133
|
+
case 'auto_walk':
|
|
8134
|
+
case 'auto_breath':
|
|
8135
|
+
case 'auto_idle':
|
|
8136
|
+
case 'auto_jump': {
|
|
8137
|
+
const method = { auto_walk: 'autoWalk', auto_breath: 'autoBreath', auto_idle: 'autoIdle', auto_jump: 'autoJump' }[input.action];
|
|
8138
|
+
// These return null when the rig has no bone the preset recognises BY
|
|
8139
|
+
// NAME, which is a real refusal and not an error — but `result: null`
|
|
8140
|
+
// tells an agent nothing, and a silent nothing is what sends it into a
|
|
8141
|
+
// retry loop. Say which names each one looks for.
|
|
8142
|
+
const needs = {
|
|
8143
|
+
// autoWalk cycles through SAVED poses named walk_00…walk_07 or
|
|
8144
|
+
// walk_contact_L/walk_passing_L/… — the ones the `humanoid` library
|
|
8145
|
+
// saves. It is not a procedural generator, it is a pose player, and
|
|
8146
|
+
// saying "needs a skeleton" when the skeleton is right there sends an
|
|
8147
|
+
// agent looking in the wrong place.
|
|
8148
|
+
auto_walk: "at least 2 saved walk poses (walk_00…, or walk_contact_L / walk_passing_L / walk_contact_R / walk_passing_R). Run load_pose_library with libraryName 'humanoid' (or 'quadruped') first — it saves exactly those",
|
|
8149
|
+
auto_breath: 'a bone named spine, chest, upper_spine or body',
|
|
8150
|
+
auto_idle: 'a bone named head, hip, upper_hub or spine',
|
|
8151
|
+
auto_jump: 'a skeleton, and saved jump poses if you want a posed jump rather than a root arc',
|
|
8152
|
+
};
|
|
8153
|
+
return wrap(`Rigging: ${input.action}`, ` const res = R.${method}(${S(input.skeletonId)}, ${S(input.options ?? {})});
|
|
8154
|
+
if (res && app.historyManager) app.historyManager.saveState();
|
|
8155
|
+
if (!res) { return { success: false, action: ${S(input.action)}, error: ${S(`refused — needs ${needs[input.action]}.`)} }; }
|
|
8156
|
+
return { success: true, action: ${S(input.action)}, result: res };`);
|
|
8157
|
+
}
|
|
8158
|
+
case 'move_root':
|
|
8159
|
+
return wrap('Rigging: root locomotion track', ` const ok = R.moveRoot(${S(input.skeletonId)}, ${S(input.keyframes ?? [])}, ${S(input.options ?? {})});
|
|
8160
|
+
if (!ok) { return { success: false, action: 'move_root', error: "no such skeleton, or keyframes was empty. Each key is { t, x, y } with t in SCENE SECONDS" }; }
|
|
8161
|
+
return { success: true, action: 'move_root', keys: ${(input.keyframes ?? []).length} };`);
|
|
8162
|
+
case 'stop_root_track':
|
|
8163
|
+
return wrap('Rigging: stop root track', ` const ok = R.stopRootTrack(${S(input.skeletonId)});
|
|
8164
|
+
if (!ok) { return { success: false, action: 'stop_root_track', error: "no root track was running on that skeleton — this is a no-op rather than a failure" }; }
|
|
8165
|
+
return { success: true, action: 'stop_root_track' };`);
|
|
8166
|
+
case 'add_secondary_motion':
|
|
8167
|
+
return wrap('Rigging: secondary motion', ` const res = R.addSecondaryMotion(${S(input.skeletonId)}, ${S(input.boneNames ?? [])}, ${S(input.options ?? {})});
|
|
8168
|
+
if (!res) { return { success: false, action: 'add_secondary_motion', error: "no such skeleton, or fewer than 2 of the given bone NAMES resolve on it. A spring chain needs at least two; list_bones for the names this rig uses" }; }
|
|
8169
|
+
return { success: true, action: 'add_secondary_motion', bones: ${(input.boneNames ?? []).length} };`);
|
|
8170
|
+
case 'skin_path':
|
|
8171
|
+
return wrap('Rigging: skin path to bones', ` const res = R.skinPath(${S(input.skeletonId)}, ${S(input.itemId)}, ${S(input.options ?? {})});
|
|
8172
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
8173
|
+
if (!res) { return { success: false, action: 'skin_path', error: "no such skeleton or item, the item is not a path, or no bone is close enough to influence it. Skinning binds vertices to nearby bones, so the path has to overlap the rig — call list_bones to see where they are" }; }
|
|
8174
|
+
return { success: true, action: 'skin_path', itemId: ${S(input.itemId)} };`);
|
|
8175
|
+
case 'bake_animation':
|
|
8176
|
+
return wrap('Rigging: bake animation to keyframes', ` const res = R.bakeAnimation(${S(input.skeletonId)}, ${S(input.options ?? {})});
|
|
8177
|
+
if (res && app.historyManager) app.historyManager.saveState();
|
|
8178
|
+
if (!res) { return { success: false, action: 'bake_animation', error: "nothing to bake — no such skeleton, or no items are ATTACHED to its bones. Baking writes keyframes onto the attached items, so a bare skeleton has no output." }; }
|
|
8179
|
+
return { success: true, action: 'bake_animation', result: res };`);
|
|
8180
|
+
case 'list_shape_keys':
|
|
8181
|
+
return wrap('Rigging: list shape keys', ` const keys = R.listShapeKeys(${S(input.skeletonId)});
|
|
8182
|
+
return { success: true, action: 'list_shape_keys', shapeKeys: keys, count: keys.length };`);
|
|
8183
|
+
case 'load_shape_key':
|
|
8184
|
+
return wrap('Rigging: load shape key', ` const ok = R.loadShapeKey(${S(input.skeletonId)}, ${S(input.shapeKeyId)}, ${input.weight ?? 1});
|
|
8185
|
+
if (!ok) { return { success: false, action: 'load_shape_key', error: "no such skeleton, or no such shape key on it. list_shape_keys for what is saved" }; }
|
|
8186
|
+
return { success: true, action: 'load_shape_key', weight: ${input.weight ?? 1} };`);
|
|
6034
8187
|
// ── Mocap / rig import — these live on `app` (PinePaper facades), not
|
|
6035
8188
|
// riggingSystem, and they are async, so they get their own async IIFE
|
|
6036
8189
|
// instead of the sync `wrap` above. Each returns the facade's own result
|
|
@@ -6217,6 +8370,21 @@ ${needWorld}
|
|
|
6217
8370
|
* object. Guards on the app method so old FxTool builds degrade gracefully.
|
|
6218
8371
|
*/
|
|
6219
8372
|
generateEquationPath(input) {
|
|
8373
|
+
// An ODE is INTEGRATED, not plotted: the caller gets the trajectory back as
|
|
8374
|
+
// data rather than a drawn path, so it can be inspected, fed to a path, or
|
|
8375
|
+
// used to drive keyframes. Branching here rather than in a separate tool
|
|
8376
|
+
// keeps one place that knows the equation vocabulary.
|
|
8377
|
+
if (input.solveOde) {
|
|
8378
|
+
const ode = input.solveOde;
|
|
8379
|
+
return `
|
|
8380
|
+
// Integrate an ODE and return the SOLUTION — nothing is drawn
|
|
8381
|
+
(async function() {
|
|
8382
|
+
if (typeof app.solveODE !== 'function') { return { success: false, error: 'app.solveODE unavailable — update FxTool' }; }
|
|
8383
|
+
const sol = await app.solveODE(${JSON.stringify({ equations: ode.equations, initialState: ode.initialState, tEnd: ode.tEnd, dt: ode.dt })}, ${JSON.stringify(ode.method ?? 'rk4')});
|
|
8384
|
+
if (!sol) { return { success: false, error: 'the solver returned nothing — check the equations and the initial state length' }; }
|
|
8385
|
+
return { success: true, method: ${JSON.stringify(ode.method ?? 'rk4')}, solution: sol };
|
|
8386
|
+
})();`.trim();
|
|
8387
|
+
}
|
|
6220
8388
|
const optsJson = JSON.stringify(input);
|
|
6221
8389
|
const kindJson = JSON.stringify(input.kind);
|
|
6222
8390
|
return `
|