@pinepaper.studio/mcp-server 1.6.19 → 1.6.21
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 +1 -1
- package/README.es.md +1 -1
- package/README.fr.md +1 -1
- package/README.hi.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +161 -5
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/ontology/upstream-vocabulary.d.ts +2 -2
- package/dist/ontology/upstream-vocabulary.js +3 -3
- package/dist/ontology/upstream-vocabulary.js.map +1 -1
- package/dist/ontology/vocabulary.d.ts.map +1 -1
- package/dist/ontology/vocabulary.js +9 -1
- package/dist/ontology/vocabulary.js.map +1 -1
- package/dist/tools/choreograph.d.ts +64 -0
- package/dist/tools/choreograph.d.ts.map +1 -0
- package/dist/tools/choreograph.js +346 -0
- package/dist/tools/choreograph.js.map +1 -0
- package/dist/tools/compact-descriptions.js +1 -1
- package/dist/tools/craft-tools.d.ts +55 -0
- package/dist/tools/craft-tools.d.ts.map +1 -0
- package/dist/tools/craft-tools.js +139 -0
- package/dist/tools/craft-tools.js.map +1 -0
- package/dist/tools/definitions.d.ts +10 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +401 -32
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/effect-check.d.ts +26 -0
- package/dist/tools/effect-check.d.ts.map +1 -0
- package/dist/tools/effect-check.js +129 -0
- package/dist/tools/effect-check.js.map +1 -0
- package/dist/tools/effect-class.d.ts +31 -0
- package/dist/tools/effect-class.d.ts.map +1 -0
- package/dist/tools/effect-class.js +176 -0
- package/dist/tools/effect-class.js.map +1 -0
- package/dist/tools/engine-lists.d.ts +6 -6
- package/dist/tools/engine-lists.d.ts.map +1 -1
- package/dist/tools/engine-lists.js +11 -9
- package/dist/tools/engine-lists.js.map +1 -1
- package/dist/tools/engine-methods.d.ts.map +1 -1
- package/dist/tools/engine-methods.js +12 -0
- package/dist/tools/engine-methods.js.map +1 -1
- package/dist/tools/engine-surface.d.ts +1 -1
- package/dist/tools/engine-surface.d.ts.map +1 -1
- package/dist/tools/engine-surface.js +221 -10
- package/dist/tools/engine-surface.js.map +1 -1
- package/dist/tools/handlers/export.d.ts.map +1 -1
- package/dist/tools/handlers/export.js +8 -1
- package/dist/tools/handlers/export.js.map +1 -1
- package/dist/tools/handlers/generate.d.ts +28 -28
- package/dist/tools/handlers.d.ts +23 -0
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/tools/handlers.js +301 -6
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/minimal-descriptions.d.ts.map +1 -1
- package/dist/tools/minimal-descriptions.js +16 -3
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/shape-params.d.ts +2 -2
- package/dist/tools/shape-params.d.ts.map +1 -1
- package/dist/tools/shape-params.js +25 -2
- package/dist/tools/shape-params.js.map +1 -1
- package/dist/tools/stick-vocabulary.d.ts +1 -1
- package/dist/tools/stick-vocabulary.js +1 -1
- package/dist/tools/toolkits.d.ts.map +1 -1
- package/dist/tools/toolkits.js +15 -0
- package/dist/tools/toolkits.js.map +1 -1
- package/dist/types/code-generator.d.ts +44 -1
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +1140 -128
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +3091 -584
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +308 -6
- package/dist/types/schemas.js.map +1 -1
- package/dist/vendor/design/design-style.d.ts +2 -2
- package/dist/vendor/design/design-style.d.ts.map +1 -1
- package/dist/vendor/design/design-style.js +7 -2
- package/dist/vendor/design/design-style.js.map +1 -1
- package/dist/vendor/design/subdivision-tool-schema.d.ts +16 -0
- package/dist/vendor/design/subdivision-tool-schema.d.ts.map +1 -0
- package/dist/vendor/design/subdivision-tool-schema.js +104 -0
- package/dist/vendor/design/subdivision-tool-schema.js.map +1 -0
- package/dist/vendor/design/textile-tool-schema.d.ts +14 -0
- package/dist/vendor/design/textile-tool-schema.d.ts.map +1 -0
- package/dist/vendor/design/textile-tool-schema.js +195 -0
- package/dist/vendor/design/textile-tool-schema.js.map +1 -0
- package/dist/vendor/design/ui-kit-runtime.d.ts +49 -0
- package/dist/vendor/design/ui-kit-runtime.d.ts.map +1 -0
- package/dist/vendor/design/ui-kit-runtime.js +761 -0
- package/dist/vendor/design/ui-kit-runtime.js.map +1 -0
- package/dist/vendor/design/ui-kit-tool-schema.d.ts +280 -0
- package/dist/vendor/design/ui-kit-tool-schema.d.ts.map +1 -0
- package/dist/vendor/design/ui-kit-tool-schema.js +209 -0
- package/dist/vendor/design/ui-kit-tool-schema.js.map +1 -0
- package/manifest.json +53 -1
- package/package.json +5 -4
- package/dist/assets/repositories/openclipart.d.ts +0 -41
- package/dist/assets/repositories/openclipart.d.ts.map +0 -1
- package/dist/assets/repositories/openclipart.js +0 -139
- package/dist/assets/repositories/openclipart.js.map +0 -1
- package/dist/assets/repositories/svgrepo.d.ts +0 -61
- package/dist/assets/repositories/svgrepo.d.ts.map +0 -1
- package/dist/assets/repositories/svgrepo.js +0 -276
- package/dist/assets/repositories/svgrepo.js.map +0 -1
|
@@ -4,12 +4,13 @@
|
|
|
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 { compileChoreography } from '../tools/choreograph.js';
|
|
7
8
|
import { normalizeSvgSize } from '../utils/svg-size.js';
|
|
8
9
|
import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
|
|
9
10
|
import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
|
|
10
11
|
import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
|
|
11
12
|
import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
|
|
12
|
-
import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
13
|
+
import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
|
|
13
14
|
// Diagram types
|
|
14
15
|
CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
|
|
15
16
|
// Agent flow mode types
|
|
@@ -73,9 +74,11 @@ function normalizeGradientSpec(v, key) {
|
|
|
73
74
|
*/
|
|
74
75
|
function generateGradientCode(gradient, itemVarName, position, propertyName = 'fillColor') {
|
|
75
76
|
const { type, stops, origin, destination } = gradient;
|
|
76
|
-
// Calculate default origin/destination based on item position
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Calculate default origin/destination based on item position.
|
|
78
|
+
// For radial gradients, origin defaults to the item center (position.x, position.y),
|
|
79
|
+
// not an offset (-50, -50), and destination to the radius edge (B88).
|
|
80
|
+
const defaultOrigin = origin || (type === 'radial' ? [position.x, position.y] : [position.x - 50, position.y - 50]);
|
|
81
|
+
const defaultDestination = destination || (type === 'radial' ? [position.x + 50, position.y] : [position.x + 50, position.y + 50]);
|
|
79
82
|
const stopsCode = stops.map(s => `['${s.color}', ${s.offset}]`).join(', ');
|
|
80
83
|
return `
|
|
81
84
|
${itemVarName}.${propertyName} = {
|
|
@@ -129,6 +132,18 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
|
|
|
129
132
|
properties = { ...properties, [key]: g.gradient };
|
|
130
133
|
}
|
|
131
134
|
}
|
|
135
|
+
// Aliases for text items: text -> content (B66), textAlign / align -> justification (B89)
|
|
136
|
+
if (properties.content === undefined && properties.text !== undefined) {
|
|
137
|
+
properties = { ...properties, content: properties.text };
|
|
138
|
+
}
|
|
139
|
+
if (properties.justification === undefined) {
|
|
140
|
+
if (properties.textAlign !== undefined) {
|
|
141
|
+
properties = { ...properties, justification: properties.textAlign };
|
|
142
|
+
}
|
|
143
|
+
else if (properties.align !== undefined) {
|
|
144
|
+
properties = { ...properties, justification: properties.align };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
132
147
|
// Extract special properties that need separate handling
|
|
133
148
|
const { color, fillColor, strokeColor, shadowColor, shadowBlur, shadowOffset, blendMode, opacity, contentType, contentFormat, countdownTarget, countdownEndText, ...baseProperties } = properties;
|
|
134
149
|
// A PATH KEEPS ITS OWN COORDINATES WHEN NO POSITION WAS ASKED FOR (round 9
|
|
@@ -193,18 +208,15 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
|
|
|
193
208
|
if (strokeColor && !isGradient(strokeColor)) {
|
|
194
209
|
params.strokeColor = strokeColor;
|
|
195
210
|
}
|
|
196
|
-
// RENDER-TIME SURFACES
|
|
197
|
-
//
|
|
198
|
-
// `shader` and `field` are drawn per pixel by the cloud rasterizer from the
|
|
199
|
-
// scene document; the browser engine has no such item type and answers
|
|
200
|
-
// `create('shader')` with "unknown type — nothing was created". Emitting that
|
|
201
|
-
// call would hand every local user a silent hole where their backdrop was.
|
|
211
|
+
// RENDER-TIME SURFACES ARE A TAGGED PLATE.
|
|
202
212
|
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
213
|
+
// `shader` and `field` are drawn per pixel from the scene document. What is
|
|
214
|
+
// created is a rectangle carrying the id, position, size and colour, tagged
|
|
215
|
+
// data.renderAs + data.renderParams. The studio adopts that plate and draws
|
|
216
|
+
// the surface over it in the live view, stills and every export (FxTool D19);
|
|
217
|
+
// the cloud rasterizer draws it from the same tags. A studio too old to adopt
|
|
218
|
+
// it still shows a readable, addressable plate rather than a hole, which is
|
|
219
|
+
// why this does not call create('shader').
|
|
208
220
|
if (itemType === 'shader' || itemType === 'field') {
|
|
209
221
|
const plate = {
|
|
210
222
|
x: position.x,
|
|
@@ -218,8 +230,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
|
|
|
218
230
|
if (params.opacity != null)
|
|
219
231
|
plate.opacity = params.opacity;
|
|
220
232
|
return `
|
|
221
|
-
// ${itemType} — a render-time surface
|
|
222
|
-
// stood in for locally by a flat plate so the layout still reads.
|
|
233
|
+
// ${itemType} — a render-time surface: a plate tagged with what to draw over it.
|
|
223
234
|
const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
|
|
224
235
|
if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
|
|
225
236
|
// The snippet's value is its last statement's, which was the assignment above:
|
|
@@ -228,10 +239,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
|
|
|
228
239
|
// \`}\` as a statement end, so a bare ternary of object literals is not wrapped
|
|
229
240
|
// and its value is lost.
|
|
230
241
|
((item && item.data)
|
|
231
|
-
? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} }
|
|
232
|
-
|
|
233
|
-
note: 'drawn here as a flat plate; the ${itemType} itself appears only in a cloud render. A local export shows the plate.' }
|
|
234
|
-
: { success: false, error: 'the stand-in plate for this ${itemType} was not created.' });`;
|
|
242
|
+
? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} } }
|
|
243
|
+
: { success: false, error: 'the plate for this ${itemType} was not created.' });`;
|
|
235
244
|
}
|
|
236
245
|
// AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
|
|
237
246
|
//
|
|
@@ -333,9 +342,14 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
|
|
|
333
342
|
if (textFit)
|
|
334
343
|
code += `\nlet __textFit = null;${emitTextFit('item', textFit, JSON.stringify(anchorHold(properties.anchor ?? properties.origin) ?? null))}`;
|
|
335
344
|
// After opacity, so the "on" level of the lifetime is the item's own.
|
|
336
|
-
|
|
345
|
+
// A numeric `lifetime` is the studio's alias of ttl on create (FxTool B125;
|
|
346
|
+
// emitters, where it means each particle's life, are not made here).
|
|
347
|
+
// Resolved here too: with bornAt beside it, passing ttl undefined would reset
|
|
348
|
+
// the item to "until the end" after create() had set the alias.
|
|
349
|
+
const createTtl = properties.ttl ?? (typeof properties.lifetime === 'number' ? properties.lifetime : undefined);
|
|
350
|
+
const hasLifetime = properties.bornAt !== undefined || createTtl !== undefined;
|
|
337
351
|
if (hasLifetime) {
|
|
338
|
-
code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt,
|
|
352
|
+
code += `\nlet __lifetime = null;${emitLifetime('item', properties.bornAt, createTtl)}`;
|
|
339
353
|
}
|
|
340
354
|
// Ensure item is visible above backgrounds/generators
|
|
341
355
|
code += `\nif (item.bringToFront) item.bringToFront();`;
|
|
@@ -402,9 +416,8 @@ const CREATE_KNOWN_KEYS = new Set([
|
|
|
402
416
|
// withRadiusAxes, gradients / shadow / blend / opacity emitted here, and
|
|
403
417
|
// setDynamicContent for text.
|
|
404
418
|
'radiusX', 'radiusY', 'contentType', 'contentFormat', 'countdownTarget', 'countdownEndText',
|
|
405
|
-
// Lifetimes
|
|
406
|
-
//
|
|
407
|
-
// them "no effect" would steer a caller off the only way to cut between shots.
|
|
419
|
+
// Lifetimes, on ANY item: this generator applies them through app.setLifetime
|
|
420
|
+
// (emitLifetime), whatever create() itself reads.
|
|
408
421
|
'bornAt', 'ttl',
|
|
409
422
|
]);
|
|
410
423
|
/**
|
|
@@ -625,7 +638,25 @@ if (typeof app.fitText === 'function') {
|
|
|
625
638
|
*
|
|
626
639
|
* lineHeight is the CSS habit: <= 4 is a multiple of fontSize, above that px.
|
|
627
640
|
*/
|
|
628
|
-
|
|
641
|
+
/**
|
|
642
|
+
* THE FRAME A MOTION IS AUTHORED AGAINST.
|
|
643
|
+
*
|
|
644
|
+
* app.exportFrameRect() is the export frame. On a bounded canvas that is the
|
|
645
|
+
* artboard. On an UNBOUNDED canvas (prod's default) it is the region the items
|
|
646
|
+
* currently occupy, padded: with one actor on the canvas it is the actor
|
|
647
|
+
* itself, so choreograph called every move "off screen" and the morph camera
|
|
648
|
+
* framed against the hero alone. An unbounded canvas has no fixed frame, so
|
|
649
|
+
* motion is checked against its nominal artboard instead, flagged unbounded so
|
|
650
|
+
* the caller can say so.
|
|
651
|
+
*/
|
|
652
|
+
function authoringFrameJs(name) {
|
|
653
|
+
return `const ${name} = (function() {
|
|
654
|
+
const cs = app.getCanvasSize && app.getCanvasSize();
|
|
655
|
+
if (cs && cs.unbounded && cs.width > 0 && cs.height > 0) return { x: 0, y: 0, width: cs.width, height: cs.height, unbounded: true };
|
|
656
|
+
return (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || cs || { width: 1920, height: 1080 };
|
|
657
|
+
})();`;
|
|
658
|
+
}
|
|
659
|
+
const TEXT_STYLE_KEYS = ['fontWeight', 'fontStyle', 'leading', 'lineHeight', 'textAlign', 'align', 'text'];
|
|
629
660
|
function emitTextStyle(itemExpr, props) {
|
|
630
661
|
const weight = props.fontWeight;
|
|
631
662
|
const italic = props.fontStyle === 'italic' || props.fontStyle === 'oblique';
|
|
@@ -710,10 +741,11 @@ function resolveChartFields(chartType, data, options) {
|
|
|
710
741
|
* same way for the same reason. A 1-microsecond tolerance makes a hand-off at
|
|
711
742
|
* an exact frame time show exactly one item.
|
|
712
743
|
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
744
|
+
* The callback is the FALLBACK, for a studio without app.setLifetime: it is
|
|
745
|
+
* not saved, so a reopened scene keeps data.bornAt / ttl (the cloud renderer
|
|
746
|
+
* reads those) but not the hiding. app.setLifetime persists; the result's
|
|
747
|
+
* lifetime field says which ran. Lifetimes applied the old way are migrated:
|
|
748
|
+
* their tagged opacity keys are removed.
|
|
717
749
|
*/
|
|
718
750
|
function emitLifetime(itemExpr, bornAt, ttl) {
|
|
719
751
|
const b = Number(bornAt);
|
|
@@ -792,6 +824,27 @@ function emitLifetime(itemExpr, bornAt, ttl) {
|
|
|
792
824
|
*/
|
|
793
825
|
function generateModifyItemCode(itemId, properties, dataFlags) {
|
|
794
826
|
const { contentType, contentFormat, countdownTarget, countdownEndText, ...restProperties } = properties;
|
|
827
|
+
// Aliases for text items: text -> content (B66), textAlign / align -> justification (B89).
|
|
828
|
+
// A key consumed here was applied, so the unread-key report below must not
|
|
829
|
+
// call it "no effect".
|
|
830
|
+
const aliased = new Set();
|
|
831
|
+
if (restProperties.justification === undefined) {
|
|
832
|
+
if (restProperties.textAlign !== undefined) {
|
|
833
|
+
restProperties.justification = restProperties.textAlign;
|
|
834
|
+
delete restProperties.textAlign;
|
|
835
|
+
aliased.add('textAlign');
|
|
836
|
+
}
|
|
837
|
+
else if (restProperties.align !== undefined) {
|
|
838
|
+
restProperties.justification = restProperties.align;
|
|
839
|
+
delete restProperties.align;
|
|
840
|
+
aliased.add('align');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (restProperties.content === undefined && restProperties.text !== undefined) {
|
|
844
|
+
restProperties.content = restProperties.text;
|
|
845
|
+
delete restProperties.text;
|
|
846
|
+
aliased.add('text');
|
|
847
|
+
}
|
|
795
848
|
// A new fit box is applied here (emitTextFit, with the hold carried over),
|
|
796
849
|
// not by modifyItem: the engine's modify fits it with no hold, overwriting
|
|
797
850
|
// the one this reads back. fit: null still goes through — that is the
|
|
@@ -927,7 +980,7 @@ if (_flagged && _flagged.item && _flagged.item.data) Object.assign(_flagged.item
|
|
|
927
980
|
// AN UNREAD KEY HAD NO EFFECT, AND THE CALL SAID SUCCESS. {scaling: 8} on a
|
|
928
981
|
// raster answered success and left it 16 px; the engine reads `scale`. Same
|
|
929
982
|
// report create_item gives, against the engine's modify path (generated).
|
|
930
|
-
const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k));
|
|
983
|
+
const modIgnored = Object.keys(properties).filter((k) => !MODIFY_KNOWN_KEYS.has(k) && !aliased.has(k));
|
|
931
984
|
const modWarning = modIgnored.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(modIgnored)}, warning: ${JSON.stringify(`${modIgnored.join(', ')} ${modIgnored.length > 1 ? 'are' : 'is'} not read when modifying an item, so ${modIgnored.length > 1 ? 'they had' : 'it had'} no effect.`
|
|
932
985
|
+ modIgnored.filter((k) => MODIFY_DID_YOU_MEAN[k]).map((k) => ` Did you mean ${MODIFY_DID_YOU_MEAN[k]} (not ${k})?`).join(''))}`;
|
|
933
986
|
code += `
|
|
@@ -1224,10 +1277,22 @@ app.animate(item, {
|
|
|
1224
1277
|
*/
|
|
1225
1278
|
function normalizeKeyframePositions(keyframes) {
|
|
1226
1279
|
return (keyframes || []).map((kf) => {
|
|
1227
|
-
const
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1280
|
+
const rawProps = kf?.properties;
|
|
1281
|
+
const props = rawProps && typeof rawProps === 'object' ? rawProps : {};
|
|
1282
|
+
const p = props.position ?? kf?.position;
|
|
1283
|
+
if (p) {
|
|
1284
|
+
if (Array.isArray(p) && p.length >= 2) {
|
|
1285
|
+
return { ...kf, properties: { ...props, position: [p[0], p[1]] } };
|
|
1286
|
+
}
|
|
1287
|
+
if (typeof p === 'object' && 'x' in p && 'y' in p) {
|
|
1288
|
+
return { ...kf, properties: { ...props, position: [p.x, p.y] } };
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (kf?.x !== undefined && kf?.y !== undefined && props.position === undefined && props.x === undefined && props.y === undefined) {
|
|
1292
|
+
return { ...kf, properties: { ...props, position: [kf.x, kf.y] } };
|
|
1293
|
+
}
|
|
1294
|
+
if (rawProps === undefined && Object.keys(props).length > 0) {
|
|
1295
|
+
return { ...kf, properties: props };
|
|
1231
1296
|
}
|
|
1232
1297
|
return kf;
|
|
1233
1298
|
});
|
|
@@ -1250,9 +1315,14 @@ function normalizeKeyframePositions(keyframes) {
|
|
|
1250
1315
|
* "item_7 is not on the canvas" is actionable, silence is not.
|
|
1251
1316
|
*/
|
|
1252
1317
|
function requireItem(itemId, what) {
|
|
1318
|
+
const isRef = typeof itemId === 'string' && itemId.startsWith('$');
|
|
1319
|
+
const targetExpr = isRef
|
|
1320
|
+
? `((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids && window.__pinepaper_batch_item_ids[${itemId.substring(1)}]) || '${itemId}')`
|
|
1321
|
+
: `'${itemId}'`;
|
|
1253
1322
|
return `
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1323
|
+
const _targetId = ${targetExpr};
|
|
1324
|
+
const _target = (app.getItemById && app.getItemById(_targetId))
|
|
1325
|
+
|| (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(_targetId));
|
|
1256
1326
|
if (!_target) {
|
|
1257
1327
|
return { success: false, error: ${JSON.stringify(itemId)} + ' is not on the canvas, so ' + ${JSON.stringify(what)} + ' would have done nothing. Check the id with pinepaper_get_items — create returns the registry id, not the Paper id.' };
|
|
1258
1328
|
}`.trim();
|
|
@@ -1286,7 +1356,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
1286
1356
|
// is the feature check: a key it names for audio passes through, anything
|
|
1287
1357
|
// else on an audio item is still refused (prod, until that build ships).
|
|
1288
1358
|
const audioLevelGuard = levelKeys.length === 0 ? '' : `
|
|
1289
|
-
const __ae = app.itemRegistry && app.itemRegistry.get(
|
|
1359
|
+
const __ae = app.itemRegistry && app.itemRegistry.get(_targetId);
|
|
1290
1360
|
let __audioKeyed = [];
|
|
1291
1361
|
try {
|
|
1292
1362
|
const __l = typeof app.listAnimatableProperties === 'function' ? app.listAnimatableProperties() : null;
|
|
@@ -1294,7 +1364,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
1294
1364
|
} catch (_) { /* treat as unsupported */ }
|
|
1295
1365
|
const __unsupported = ${JSON.stringify(levelKeys)}.filter(function(k) { return __audioKeyed.indexOf(k) === -1; });
|
|
1296
1366
|
if (__ae && __ae.type === 'audio' && __unsupported.length) {
|
|
1297
|
-
return { success: false, itemId:
|
|
1367
|
+
return { success: false, itemId: _targetId, error: __unsupported.join(', ') + ' cannot be keyframed on an audio item in this studio: it mixes each audio clip at one fixed level, so a fade would be accepted and not heard. Nothing was added. '
|
|
1298
1368
|
+ (__audioKeyed.length ? 'Keyable on audio here: ' + __audioKeyed.join(', ') + '. ' : '')
|
|
1299
1369
|
+ 'Set a static level with pinepaper_modify_item {audioGain: 0..1}.' };
|
|
1300
1370
|
}`;
|
|
@@ -1327,6 +1397,10 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
1327
1397
|
const easingNote = first?.easing && first.easing !== 'linear'
|
|
1328
1398
|
? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to.`)}`
|
|
1329
1399
|
: '';
|
|
1400
|
+
const unsupportedKeys = [...new Set(keyframes.flatMap((k) => Object.keys(k.properties ?? {})))]
|
|
1401
|
+
.filter((k) => k === 'letterSpacing');
|
|
1402
|
+
const letterSpacingWarning = unsupportedKeys.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(unsupportedKeys)}, warning: ${JSON.stringify(`${unsupportedKeys.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
|
|
1403
|
+
const addAnimTarget = itemId.startsWith('$') ? '_targetId' : `'${itemId}'`;
|
|
1330
1404
|
return `
|
|
1331
1405
|
// Apply keyframe animation to ${itemId}
|
|
1332
1406
|
(function() {
|
|
@@ -1336,7 +1410,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
1336
1410
|
// merge support — an older engine takes the option and replaces anyway.
|
|
1337
1411
|
const __ki = _target && (_target.item || _target);
|
|
1338
1412
|
const __prev = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
|
|
1339
|
-
app.addAnimation(
|
|
1413
|
+
app.addAnimation(${addAnimTarget}, ${keyframesJson}, ${JSON.stringify(opts)});
|
|
1340
1414
|
const __now = (__ki && __ki.data && Array.isArray(__ki.data.keyframes)) ? __ki.data.keyframes.map(function(k) { return k && k.time; }) : [];
|
|
1341
1415
|
const __kept = __prev.every(function(t) { return __now.indexOf(t) >= 0; });${append ? `
|
|
1342
1416
|
// Property gaps across the merged track (the same check a single call makes
|
|
@@ -1349,7 +1423,7 @@ function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false,
|
|
|
1349
1423
|
? { appended: true, previousKeys: __prev.length, keys: __now.length }
|
|
1350
1424
|
: { appended: false, replaced: true, previousKeys: __prev.length, warning: 'append was asked for, but this studio replaced the existing track (' + __prev.length + ' key(s)) instead of adding to it: it has no merge mode. Put every key in one keyframe_animate call.' })`
|
|
1351
1425
|
: `{ replaced: true, previousKeys: __prev.length, note: 'the item already had a keyframe track (' + __prev.length + ' key(s)); this call replaced it. To add keys to it instead, pass append: true.' }`};
|
|
1352
|
-
return { success: true, itemId:
|
|
1426
|
+
return { success: true, itemId: ${addAnimTarget}, duration: ${calculatedDuration}, loop: ${loop}${append ? ', append: true' : ''}, ...(__track ? { track: __track } : {})${append ? `, ...(__gaps.length ? { propertyGaps: __gaps.slice(0, 10), gapWarning: __gaps.length + ' key(s) of the merged track leave out a property other keys animate (' + __gaps.reduce(function(a, g) { g.missing.forEach(function(p) { if (a.indexOf(p) < 0) a.push(p); }); return a; }, []).join(', ') + '). The engine can break that property at such a key; give each key the full set — hold a value by repeating it.' } : {})` : ''}${timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : ''}${clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : ''}${clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : ''}${easingNote}${loopNote}${gapNote}${letterSpacingWarning} };
|
|
1353
1427
|
})();
|
|
1354
1428
|
`.trim();
|
|
1355
1429
|
}
|
|
@@ -1895,6 +1969,54 @@ export function isCoordinateBuilt(itemType, props) {
|
|
|
1895
1969
|
return false;
|
|
1896
1970
|
}
|
|
1897
1971
|
}
|
|
1972
|
+
/** A keyframe's properties, with the flat spellings (x, opacity, ...) lifted in beside `properties`. */
|
|
1973
|
+
function extractKfPropsMap(kf) {
|
|
1974
|
+
const props = (kf && kf.properties && typeof kf.properties === 'object') ? { ...kf.properties } : {};
|
|
1975
|
+
if (kf) {
|
|
1976
|
+
const flat = kf;
|
|
1977
|
+
for (const key of ['x', 'y', 'opacity', 'scale', 'scaleX', 'scaleY', 'rotation', 'position', 'fill', 'fillColor', 'stroke', 'strokeColor', 'width', 'height', 'radius', 'lineHeight']) {
|
|
1978
|
+
if (flat[key] !== undefined && props[key] === undefined)
|
|
1979
|
+
props[key] = flat[key];
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return props;
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* One op's track sampled at time t: held before its first key and after its last, linear in between
|
|
1986
|
+
* (numbers and equal-length number arrays; anything else holds until the next key is reached).
|
|
1987
|
+
*/
|
|
1988
|
+
function sampleOpTrack(kfs, t) {
|
|
1989
|
+
if (!kfs.length)
|
|
1990
|
+
return {};
|
|
1991
|
+
if (kfs.length === 1 || t <= kfs[0].time)
|
|
1992
|
+
return extractKfPropsMap(kfs[0]);
|
|
1993
|
+
if (t >= kfs[kfs.length - 1].time)
|
|
1994
|
+
return extractKfPropsMap(kfs[kfs.length - 1]);
|
|
1995
|
+
let i = 0;
|
|
1996
|
+
for (let j = 0; j < kfs.length - 1; j++) {
|
|
1997
|
+
if (t >= kfs[j].time && t <= kfs[j + 1].time) {
|
|
1998
|
+
i = j;
|
|
1999
|
+
break;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const k0 = kfs[i], k1 = kfs[i + 1];
|
|
2003
|
+
const p0 = extractKfPropsMap(k0), p1 = extractKfPropsMap(k1);
|
|
2004
|
+
const f = Math.max(0, Math.min(1, (t - k0.time) / (k1.time - k0.time || 1)));
|
|
2005
|
+
const out = {};
|
|
2006
|
+
for (const k of new Set([...Object.keys(p0), ...Object.keys(p1)])) {
|
|
2007
|
+
const v0 = p0[k], v1 = p1[k];
|
|
2008
|
+
if (typeof v0 === 'number' && typeof v1 === 'number') {
|
|
2009
|
+
out[k] = v0 + (v1 - v0) * f;
|
|
2010
|
+
}
|
|
2011
|
+
else if (Array.isArray(v0) && Array.isArray(v1) && v0.length === v1.length && typeof v0[0] === 'number') {
|
|
2012
|
+
out[k] = v0.map((n, idx) => Number(n) + (Number(v1[idx]) - Number(n)) * f);
|
|
2013
|
+
}
|
|
2014
|
+
else {
|
|
2015
|
+
out[k] = (v1 !== undefined && f >= 1) ? v1 : (v0 !== undefined ? v0 : v1);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
return out;
|
|
2019
|
+
}
|
|
1898
2020
|
/**
|
|
1899
2021
|
* app.addAnimation() REPLACES data.keyframes wholesale, so two
|
|
1900
2022
|
* keyframe_animate ops targeting one item meant the first track was silently
|
|
@@ -1922,28 +2044,34 @@ export function planKeyframeMerges(operations) {
|
|
|
1922
2044
|
if (indices.length < 2)
|
|
1923
2045
|
continue;
|
|
1924
2046
|
const first = indices[0];
|
|
1925
|
-
// Union keyframes by time; later ops shallow-merge over earlier ones.
|
|
1926
|
-
const byTime = new Map();
|
|
1927
2047
|
let duration = 0;
|
|
1928
2048
|
let loop = false;
|
|
1929
2049
|
for (const i of indices) {
|
|
1930
2050
|
const op = operations[i];
|
|
1931
|
-
for (const kf of op.keyframes || []) {
|
|
1932
|
-
const prev = byTime.get(kf.time);
|
|
1933
|
-
byTime.set(kf.time, {
|
|
1934
|
-
time: kf.time,
|
|
1935
|
-
properties: { ...(prev?.properties || {}), ...(kf.properties || {}) },
|
|
1936
|
-
...((kf.easing ?? prev?.easing) !== undefined ? { easing: kf.easing ?? prev?.easing } : {}),
|
|
1937
|
-
});
|
|
1938
|
-
}
|
|
1939
2051
|
const opMax = op.keyframes?.length ? Math.max(...op.keyframes.map(k => k.time)) : 0;
|
|
1940
2052
|
duration = Math.max(duration, op.duration || opMax);
|
|
1941
2053
|
loop = loop || op.loop === true;
|
|
1942
2054
|
if (i !== first)
|
|
1943
2055
|
plan.foldedInto.set(i, first);
|
|
1944
2056
|
}
|
|
2057
|
+
// B120: at every key time of ANY op, each op contributes its own track SAMPLED there (held outside
|
|
2058
|
+
// its keys, interpolated between them), later ops winning. A plain union by timestamp left a key
|
|
2059
|
+
// holding only one op's properties, so the engine broke the other op's channel at that key.
|
|
2060
|
+
const allTimes = [...new Set(indices.flatMap((i) => (operations[i].keyframes || []).map((k) => k.time)))].sort((a, b) => a - b);
|
|
2061
|
+
const mergedKeyframes = allTimes.map((t) => {
|
|
2062
|
+
let properties = {};
|
|
2063
|
+
let easing;
|
|
2064
|
+
for (const i of indices) {
|
|
2065
|
+
const kfs = operations[i].keyframes || [];
|
|
2066
|
+
properties = { ...properties, ...sampleOpTrack(kfs, t) };
|
|
2067
|
+
const exact = kfs.find((k) => k.time === t);
|
|
2068
|
+
if (exact && exact.easing !== undefined)
|
|
2069
|
+
easing = exact.easing;
|
|
2070
|
+
}
|
|
2071
|
+
return { time: t, properties, ...(easing !== undefined ? { easing } : {}) };
|
|
2072
|
+
});
|
|
1945
2073
|
plan.merged.set(first, {
|
|
1946
|
-
keyframes:
|
|
2074
|
+
keyframes: mergedKeyframes,
|
|
1947
2075
|
duration: duration || 5,
|
|
1948
2076
|
loop,
|
|
1949
2077
|
});
|
|
@@ -1955,6 +2083,108 @@ export function planKeyframeMerges(operations) {
|
|
|
1955
2083
|
* Such a snippet's value is its last top-level (-led statement; the
|
|
1956
2084
|
* font-loading form is an async IIFE, which is awaited instead.
|
|
1957
2085
|
*/
|
|
2086
|
+
/**
|
|
2087
|
+
* The active brand's defaults for a new item (D69): only what the caller did
|
|
2088
|
+
* not name. Closed shapes take colors.primary; text takes colors.text (or
|
|
2089
|
+
* primary) and the heading font from 32 px up, else the body font. Applied
|
|
2090
|
+
* through modifyItem after create, so the engine's own bookkeeping runs.
|
|
2091
|
+
*/
|
|
2092
|
+
const BRAND_FILL_TYPES = new Set(['circle', 'star', 'rectangle', 'triangle', 'polygon', 'ellipse', 'pentagon', 'hexagon', 'diamond', 'arrow', 'heart']);
|
|
2093
|
+
function withBrandDefaults(code, itemType, props) {
|
|
2094
|
+
if (props.noBrand === true)
|
|
2095
|
+
return code;
|
|
2096
|
+
const isText = itemType === 'text';
|
|
2097
|
+
if (!isText && !BRAND_FILL_TYPES.has(itemType))
|
|
2098
|
+
return code;
|
|
2099
|
+
const hasColor = ['color', 'fillColor', 'fill'].some((k) => props[k] !== undefined);
|
|
2100
|
+
const hasFont = props.fontFamily !== undefined;
|
|
2101
|
+
if (hasColor && (!isText || hasFont))
|
|
2102
|
+
return code;
|
|
2103
|
+
const size = typeof props.fontSize === 'number' ? props.fontSize : 36;
|
|
2104
|
+
const anchor = 'const itemId = item.data.registryId;';
|
|
2105
|
+
if (!code.includes(anchor))
|
|
2106
|
+
return code;
|
|
2107
|
+
const patch = `
|
|
2108
|
+
// Active brand (D69): fill in what this call did not name.
|
|
2109
|
+
const __bk = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
|
|
2110
|
+
let __brand = null;
|
|
2111
|
+
if (__bk && __bk.colors && app.modifyItem) {
|
|
2112
|
+
const __bm = {};
|
|
2113
|
+
${hasColor ? '' : `__bm.color = ${isText ? '__bk.colors.text || __bk.colors.primary' : '__bk.colors.primary'};`}
|
|
2114
|
+
${isText && !hasFont ? `const __f = __bk.fonts && (${size >= 32 ? '__bk.fonts.heading || __bk.fonts.body' : '__bk.fonts.body || __bk.fonts.heading'}); if (__f) __bm.fontFamily = __f;` : ''}
|
|
2115
|
+
if (Object.keys(__bm).length) { app.modifyItem(itemId, __bm); __brand = __bm; }
|
|
2116
|
+
}`;
|
|
2117
|
+
return code.replace(anchor, anchor + patch).replace(/\(\{ itemId, type:/, '({ itemId, ...(__brand ? { brand: __brand } : {}), type:');
|
|
2118
|
+
}
|
|
2119
|
+
const ITEM_SETTERS = [
|
|
2120
|
+
{ key: 'screenSpace', fn: 'setScreenSpace', what: 'screen-space (HUD) items', since: 'D20' },
|
|
2121
|
+
{ key: 'stepTiming', fn: 'setStepTiming', what: 'step timing', since: 'D11' },
|
|
2122
|
+
{ key: 'counter', fn: 'setCounter', what: 'counters', since: 'D70' },
|
|
2123
|
+
];
|
|
2124
|
+
/** Lift screenSpace / hud / stepTiming out of a properties bag (mutated), explicit fields winning. */
|
|
2125
|
+
function takeItemSetters(props, explicit = {}) {
|
|
2126
|
+
const out = {};
|
|
2127
|
+
// From properties a caller may write hud: 1; it means on. The top-level
|
|
2128
|
+
// field is still a strict boolean.
|
|
2129
|
+
const ss = explicit.screenSpace ?? (props.screenSpace ?? props.hud);
|
|
2130
|
+
if (ss !== undefined)
|
|
2131
|
+
out.screenSpace = explicit.screenSpace !== undefined ? ScreenSpaceSchema.parse(ss) : !!ss;
|
|
2132
|
+
const st = explicit.stepTiming !== undefined ? explicit.stepTiming : props.stepTiming;
|
|
2133
|
+
if (st !== undefined)
|
|
2134
|
+
out.stepTiming = StepTimingSchema.parse(st);
|
|
2135
|
+
// A counter from properties is lifted only when it is a valid spec; anything
|
|
2136
|
+
// else stays where it was, for the engine to read and refuse by name.
|
|
2137
|
+
let liftCounter = explicit.counter !== undefined;
|
|
2138
|
+
if (liftCounter)
|
|
2139
|
+
out.counter = CounterSchema.parse(explicit.counter);
|
|
2140
|
+
else if (props.counter !== undefined && CounterSchema.safeParse(props.counter).success) {
|
|
2141
|
+
out.counter = props.counter;
|
|
2142
|
+
liftCounter = true;
|
|
2143
|
+
}
|
|
2144
|
+
delete props.screenSpace;
|
|
2145
|
+
delete props.hud;
|
|
2146
|
+
delete props.stepTiming;
|
|
2147
|
+
if (liftCounter)
|
|
2148
|
+
delete props.counter;
|
|
2149
|
+
return out;
|
|
2150
|
+
}
|
|
2151
|
+
function hasItemSetters(s) {
|
|
2152
|
+
return s.screenSpace !== undefined || s.stepTiming !== undefined || s.counter !== undefined;
|
|
2153
|
+
}
|
|
2154
|
+
/** Statements: refuse, before anything changes, when a requested setter is missing. */
|
|
2155
|
+
function itemSettersPreflightJs(s) {
|
|
2156
|
+
return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => ` if (typeof app.${d.fn} !== 'function') { return { success: false, error: 'this studio has no ${d.what} (app.${d.fn}) — it predates FxTool ${d.since}; nothing was changed.' }; }`).join('\n');
|
|
2157
|
+
}
|
|
2158
|
+
/** Statements: apply each setter to idExpr, folding the verdict into resVar. */
|
|
2159
|
+
function itemSettersApplyJs(s, idExpr, resVar) {
|
|
2160
|
+
return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => {
|
|
2161
|
+
const arg = JSON.stringify(s[d.key]);
|
|
2162
|
+
return ` {
|
|
2163
|
+
const __sr = app.${d.fn}(${idExpr}, ${arg});
|
|
2164
|
+
if (!__sr || __sr.ok === false) {
|
|
2165
|
+
return Object.assign({}, ${resVar}, { success: false, error: '${d.key} was not applied to ' + ${idExpr} + ': ' + ((__sr && (__sr.reason || __sr.error)) || 'refused by the studio') });
|
|
2166
|
+
}
|
|
2167
|
+
${resVar}.${d.key} = ${d.key === 'screenSpace' ? arg : `(__sr.${d.key} !== undefined ? __sr.${d.key} : ${arg})`};${d.key === 'counter' ? `\n if (__sr.text !== undefined) ${resVar}.counterText = __sr.text;` : ''}
|
|
2168
|
+
}`;
|
|
2169
|
+
}).join('\n');
|
|
2170
|
+
}
|
|
2171
|
+
/** Wrap a create / modify snippet so the setters run after it, on its item. */
|
|
2172
|
+
function withItemSetters(code, s, idExpr) {
|
|
2173
|
+
if (!hasItemSetters(s))
|
|
2174
|
+
return code;
|
|
2175
|
+
return `(async function() {
|
|
2176
|
+
${itemSettersPreflightJs(s)}
|
|
2177
|
+
const __res = await (async function() {
|
|
2178
|
+
${asReturningBody(code)}
|
|
2179
|
+
})();
|
|
2180
|
+
if (!__res || __res.success === false) return __res;
|
|
2181
|
+
const __sid = ${idExpr};
|
|
2182
|
+
if (!__sid) return __res;
|
|
2183
|
+
${itemSettersApplyJs(s, '__sid', '__res')}
|
|
2184
|
+
if (__res.success === undefined) __res.success = true;
|
|
2185
|
+
return __res;
|
|
2186
|
+
})();`;
|
|
2187
|
+
}
|
|
1958
2188
|
function asReturningBody(snippet) {
|
|
1959
2189
|
const single = snippet.trim();
|
|
1960
2190
|
if (single.startsWith('(async function()'))
|
|
@@ -2398,17 +2628,27 @@ export class PinePaperCodeGenerator {
|
|
|
2398
2628
|
properties.keyframes = validated.keyframes;
|
|
2399
2629
|
if (validated.anchor !== undefined && properties.anchor === undefined && properties.origin === undefined)
|
|
2400
2630
|
properties.anchor = validated.anchor;
|
|
2401
|
-
|
|
2631
|
+
const setters = takeItemSetters(properties, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
|
|
2632
|
+
const brandProps = { ...properties };
|
|
2633
|
+
delete properties.noBrand;
|
|
2634
|
+
return withItemSetters(withBrandDefaults(generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven), validated.itemType, brandProps), setters, '__res.itemId');
|
|
2402
2635
|
}
|
|
2403
2636
|
/**
|
|
2404
2637
|
* Generate code for modifying an item
|
|
2405
2638
|
*/
|
|
2406
2639
|
generateModifyItem(input) {
|
|
2407
2640
|
const validated = ModifyItemInputSchema.parse(input);
|
|
2641
|
+
const props = { ...validated.properties };
|
|
2642
|
+
const setters = takeItemSetters(props, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
|
|
2643
|
+
const idExpr = JSON.stringify(validated.itemId);
|
|
2408
2644
|
if (validated.atTime !== undefined) {
|
|
2409
|
-
return generateAutoKeyCode(validated.itemId,
|
|
2645
|
+
return withItemSetters(generateAutoKeyCode(validated.itemId, props, validated.atTime, validated.easing), setters, idExpr);
|
|
2646
|
+
}
|
|
2647
|
+
// Only a setter asked for: no property edit to run.
|
|
2648
|
+
if (hasItemSetters(setters) && Object.keys(props).length === 0 && !validated.data) {
|
|
2649
|
+
return withItemSetters(`({ success: true, itemId: ${idExpr} });`, setters, idExpr);
|
|
2410
2650
|
}
|
|
2411
|
-
return generateModifyItemCode(validated.itemId,
|
|
2651
|
+
return withItemSetters(generateModifyItemCode(validated.itemId, props, validated.data), setters, idExpr);
|
|
2412
2652
|
}
|
|
2413
2653
|
/**
|
|
2414
2654
|
* Generate code for deleting an item
|
|
@@ -2452,7 +2692,15 @@ export class PinePaperCodeGenerator {
|
|
|
2452
2692
|
if (validated.relationType === 'moves_along_path') {
|
|
2453
2693
|
return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
|
|
2454
2694
|
}
|
|
2455
|
-
|
|
2695
|
+
const code = generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
|
|
2696
|
+
// NO TARGET (event reactions like on_event_play_sound, self-relations): the
|
|
2697
|
+
// engine is handed the source as the target, but the reply must not say
|
|
2698
|
+
// "item_2 → item_2" — a model reads that as the event wired to itself.
|
|
2699
|
+
if (validated.targetId)
|
|
2700
|
+
return code;
|
|
2701
|
+
return code
|
|
2702
|
+
.replace(`return { success: true, sourceId: '${validated.sourceId}', targetId: '${validated.sourceId}',`, `return { success: true, sourceId: '${validated.sourceId}', targetId: null,`)
|
|
2703
|
+
.replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
|
|
2456
2704
|
}
|
|
2457
2705
|
/**
|
|
2458
2706
|
* Generate code for removing a relation
|
|
@@ -2478,7 +2726,19 @@ export class PinePaperCodeGenerator {
|
|
|
2478
2726
|
*/
|
|
2479
2727
|
generateKeyframeAnimate(input) {
|
|
2480
2728
|
const validated = KeyframeAnimateInputSchema.parse(input);
|
|
2481
|
-
|
|
2729
|
+
// Hold keys (FxTool D11) on an engine without them would interpolate in
|
|
2730
|
+
// silence; refuse before anything is written. setStepTiming ships in the
|
|
2731
|
+
// same change, so it is the probe.
|
|
2732
|
+
const holds = validated.keyframes.some((k) => k.interpolation === 'hold' || k.hold === true);
|
|
2733
|
+
const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
|
|
2734
|
+
if (!holds)
|
|
2735
|
+
return code;
|
|
2736
|
+
return `(async function() {
|
|
2737
|
+
if (!(typeof app.setStepTiming === 'function')) { return { success: false, error: 'this studio has no hold keys (interpolation: \\'hold\\') — it predates FxTool D11; nothing was written. Use two keys a frame apart for a cut.' }; }
|
|
2738
|
+
return await (async function() {
|
|
2739
|
+
${asReturningBody(code)}
|
|
2740
|
+
})();
|
|
2741
|
+
})();`;
|
|
2482
2742
|
}
|
|
2483
2743
|
/**
|
|
2484
2744
|
* Generate code for importing / retargeting a motion-capture clip
|
|
@@ -2726,6 +2986,15 @@ throw new Error('Either svgString or url must be provided');
|
|
|
2726
2986
|
success: !!result.success,
|
|
2727
2987
|
nodeCount: nodes.length,
|
|
2728
2988
|
edgeCount: edges.length,
|
|
2989
|
+
// CANVAS ids, the ones auto_layout / animate / add_relation take. The
|
|
2990
|
+
// engine's own nodeIds are the MERMAID ids (A, B…), which resolve to
|
|
2991
|
+
// nothing on the canvas (gate D45). Newer engines return nodeItemIds;
|
|
2992
|
+
// otherwise they are read off the registered nodes.
|
|
2993
|
+
nodeItemIds: Array.isArray(result.nodeItemIds) && result.nodeItemIds.length
|
|
2994
|
+
? result.nodeItemIds
|
|
2995
|
+
: nodes.map((n) => n.id).filter((id) => typeof id === 'string' && /^item_/.test(id)),
|
|
2996
|
+
...(result.nodeIdMap && typeof result.nodeIdMap === 'object' ? { nodeIdMap: result.nodeIdMap } : {}),
|
|
2997
|
+
...(Array.isArray(result.edgeIds) && result.edgeIds.length ? { edgeIds: result.edgeIds } : {}),
|
|
2729
2998
|
nodes,
|
|
2730
2999
|
edges,
|
|
2731
3000
|
errors: result.errors || [],
|
|
@@ -3596,6 +3865,8 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
3596
3865
|
let code = `
|
|
3597
3866
|
// End agent job
|
|
3598
3867
|
(async function() {
|
|
3868
|
+
// Close the studio's job (D92). It never reloads.
|
|
3869
|
+
try { if (typeof window !== 'undefined' && window.PinePaperAgent && window.PinePaperAgent.endJob) window.PinePaperAgent.endJob(); } catch (_) { /* older studio */ }
|
|
3599
3870
|
const result = {
|
|
3600
3871
|
success: true,
|
|
3601
3872
|
jobEnded: true
|
|
@@ -3815,8 +4086,19 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
3815
4086
|
|| (typeof v.reason === 'string' && v.reason)
|
|
3816
4087
|
|| 'the operation reported failure without naming a reason';
|
|
3817
4088
|
};
|
|
3818
|
-
|
|
4089
|
+
// $N is the Nth item THIS batch created (the documented contract). The
|
|
4090
|
+
// previous batch's ids are kept only to name the right id when a $N reaches
|
|
4091
|
+
// past what this batch has made: picking it silently shifted every $N in a
|
|
4092
|
+
// batch that also creates (B115 follow-up).
|
|
3819
4093
|
const itemIds = [];
|
|
4094
|
+
const __prevBatchIds = ((typeof window !== 'undefined' && window.__pinepaper_batch_item_ids) || []).slice();
|
|
4095
|
+
const __ref = function (n) {
|
|
4096
|
+
if (itemIds[n] !== undefined) return itemIds[n];
|
|
4097
|
+
const prev = __prevBatchIds[n];
|
|
4098
|
+
throw new Error('$' + n + ' is the item number ' + n + ' THIS batch created, and it has created ' + itemIds.length + ' so far.'
|
|
4099
|
+
+ (prev ? ' In the previous batch $' + n + ' was ' + prev + ' — pass that id instead.' : ' Put the create ops first, or pass the item id.'));
|
|
4100
|
+
};
|
|
4101
|
+
const results = [];
|
|
3820
4102
|
let success = true;
|
|
3821
4103
|
|
|
3822
4104
|
try {
|
|
@@ -3872,11 +4154,19 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
3872
4154
|
// 60-op batch should not have to scan sixty entries to learn that op 41
|
|
3873
4155
|
// could not find a preset.
|
|
3874
4156
|
const _failed = results.filter((r) => !r.success);
|
|
4157
|
+
// A batch that created nothing keeps the previous batch's ids on record.
|
|
4158
|
+
if (typeof window !== 'undefined' && itemIds.length) {
|
|
4159
|
+
window.__pinepaper_batch_item_ids = itemIds;
|
|
4160
|
+
}
|
|
3875
4161
|
return { success, itemIds, results, operationCount: ${operations.length},
|
|
3876
4162
|
failedCount: _failed.length,
|
|
3877
4163
|
...(_failed.length ? { error: _failed.map((r) => 'op ' + r.index + ': ' + r.error).join('; ') } : {}),
|
|
3878
4164
|
canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
|
|
3879
4165
|
} catch (e) {
|
|
4166
|
+
// A batch that created nothing keeps the previous batch's ids on record.
|
|
4167
|
+
if (typeof window !== 'undefined' && itemIds.length) {
|
|
4168
|
+
window.__pinepaper_batch_item_ids = itemIds;
|
|
4169
|
+
}
|
|
3880
4170
|
const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
|
|
3881
4171
|
return { success: false, error: e.message, itemIds, results, operationCount: ${operations.length}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
|
|
3882
4172
|
}
|
|
@@ -3899,13 +4189,47 @@ throw new Error('Unknown diagram mode action: ${action}');
|
|
|
3899
4189
|
// for a 12 s MP4 (round 8 DD, 1.67). It now runs generateCreateItemCode,
|
|
3900
4190
|
// the exact emitter create_item uses, adapted to return its result
|
|
3901
4191
|
// inside this op's function.
|
|
3902
|
-
const
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
4192
|
+
const anyOp = op;
|
|
4193
|
+
let pos = op.position;
|
|
4194
|
+
if (!pos && (typeof anyOp.x === 'number' || typeof anyOp.y === 'number')) {
|
|
4195
|
+
pos = { x: anyOp.x ?? 400, y: anyOp.y ?? 300 };
|
|
4196
|
+
}
|
|
4197
|
+
if (!pos) {
|
|
4198
|
+
pos = { x: 400, y: 300 };
|
|
4199
|
+
}
|
|
4200
|
+
const opProps = { ...(op.properties || {}) };
|
|
4201
|
+
const batchKeys = new Set([
|
|
4202
|
+
'type', 'itemType', 'position', 'properties', 'screenSpace', 'stepTiming', 'counter',
|
|
4203
|
+
'itemId', 'itemIds', 'groupName', 'animationType', 'animationOptions',
|
|
4204
|
+
'sourceId', 'targetId', 'relationType', 'relationOptions',
|
|
4205
|
+
'backgroundColor', 'generatorName', 'generatorParams', 'generatorRegion',
|
|
4206
|
+
'width', 'height', 'preset', 'keyframes', 'duration', 'loop',
|
|
4207
|
+
'maskPreset', 'maskType', 'maskOptions', 'effectType', 'effectParams',
|
|
4208
|
+
'action', 'time', 'append'
|
|
4209
|
+
]);
|
|
4210
|
+
for (const [k, v] of Object.entries(anyOp)) {
|
|
4211
|
+
if (!batchKeys.has(k) && v !== undefined && opProps[k] === undefined) {
|
|
4212
|
+
opProps[k] = v;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
if (opProps.content === undefined && opProps.text !== undefined) {
|
|
4216
|
+
opProps.content = opProps.text;
|
|
4217
|
+
}
|
|
4218
|
+
if (opProps.fillColor === undefined && opProps.color !== undefined) {
|
|
4219
|
+
opProps.fillColor = opProps.color;
|
|
4220
|
+
}
|
|
4221
|
+
const opSetters = takeItemSetters(opProps, { screenSpace: op.screenSpace, stepTiming: op.stepTiming, counter: op.counter });
|
|
4222
|
+
const createProps = withRadiusAxes(opProps);
|
|
4223
|
+
const opBrand = { ...opProps };
|
|
4224
|
+
delete opProps.noBrand;
|
|
4225
|
+
const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined || typeof anyOp.x === 'number' || typeof anyOp.y === 'number'), op.itemType, opBrand));
|
|
4226
|
+
let createCode = `${hasItemSetters(opSetters) ? `\n${itemSettersPreflightJs(opSetters)}` : ''}
|
|
3906
4227
|
const __created = await (async function() {
|
|
3907
4228
|
${returning}
|
|
3908
|
-
})()
|
|
4229
|
+
})();${hasItemSetters(opSetters) ? `
|
|
4230
|
+
if (__created && __created.success !== false && __created.itemId) {
|
|
4231
|
+
${itemSettersApplyJs(opSetters, '__created.itemId', '__created')}
|
|
4232
|
+
}` : ''}`;
|
|
3909
4233
|
// Coordinate-built items (path from segments/pathData, line/arc from
|
|
3910
4234
|
// from/through/to) derive their geometry from those coordinates and
|
|
3911
4235
|
// IGNORE params.position — so `create` at a point silently produced an
|
|
@@ -3913,7 +4237,7 @@ ${returning}
|
|
|
3913
4237
|
// caller explicitly asked for a position. Guarded on an explicit
|
|
3914
4238
|
// op.position so absolute-coordinate geometry (the common case, which
|
|
3915
4239
|
// passes no position) is never snapped to the injected default.
|
|
3916
|
-
if (op.position && isCoordinateBuilt(op.itemType, createProps)) {
|
|
4240
|
+
if ((op.position || typeof anyOp.x === 'number' || typeof anyOp.y === 'number') && isCoordinateBuilt(op.itemType, createProps)) {
|
|
3917
4241
|
createCode += `
|
|
3918
4242
|
// create() builds this item type from its own coordinates and ignores
|
|
3919
4243
|
// params.position — apply the caller's explicit position after the fact.
|
|
@@ -3927,7 +4251,7 @@ return __created;
|
|
|
3927
4251
|
}
|
|
3928
4252
|
case 'modify': {
|
|
3929
4253
|
const itemRef = op.itemId?.startsWith('$')
|
|
3930
|
-
? `
|
|
4254
|
+
? `__ref(${op.itemId.substring(1)})`
|
|
3931
4255
|
: `'${op.itemId}'`;
|
|
3932
4256
|
// modify_item's own emitter, as create is create_item's (see above):
|
|
3933
4257
|
// the batch's modifyItem call missed text styles, font loading, audio
|
|
@@ -3948,7 +4272,7 @@ return __r;
|
|
|
3948
4272
|
}
|
|
3949
4273
|
case 'animate':
|
|
3950
4274
|
const animItemRef = op.itemId?.startsWith('$')
|
|
3951
|
-
? `
|
|
4275
|
+
? `__ref(${op.itemId.substring(1)})`
|
|
3952
4276
|
: `'${op.itemId}'`;
|
|
3953
4277
|
const animOpts = JSON.stringify(op.animationOptions || {});
|
|
3954
4278
|
return `
|
|
@@ -3960,10 +4284,10 @@ return { itemId: targetId, animationType: '${op.animationType}' };
|
|
|
3960
4284
|
`;
|
|
3961
4285
|
case 'relation':
|
|
3962
4286
|
const srcRef = op.sourceId?.startsWith('$')
|
|
3963
|
-
? `
|
|
4287
|
+
? `__ref(${op.sourceId.substring(1)})`
|
|
3964
4288
|
: `'${op.sourceId}'`;
|
|
3965
4289
|
const tgtRef = op.targetId?.startsWith('$')
|
|
3966
|
-
? `
|
|
4290
|
+
? `__ref(${op.targetId.substring(1)})`
|
|
3967
4291
|
: `'${op.targetId}'`;
|
|
3968
4292
|
const relOpts = JSON.stringify(op.relationOptions || {});
|
|
3969
4293
|
return `
|
|
@@ -3978,7 +4302,7 @@ return { relationId, sourceId, targetId, relationType: '${op.relationType}' };
|
|
|
3978
4302
|
`;
|
|
3979
4303
|
case 'delete':
|
|
3980
4304
|
const delRef = op.itemId?.startsWith('$')
|
|
3981
|
-
? `
|
|
4305
|
+
? `__ref(${op.itemId.substring(1)})`
|
|
3982
4306
|
: `'${op.itemId}'`;
|
|
3983
4307
|
return `
|
|
3984
4308
|
const targetId = ${delRef};
|
|
@@ -3990,7 +4314,7 @@ return { itemId: targetId, deleted: true };
|
|
|
3990
4314
|
`;
|
|
3991
4315
|
case 'group': {
|
|
3992
4316
|
// Resolve each ref ($N → itemIds[N]; literal → quoted id) into a JS array.
|
|
3993
|
-
const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `
|
|
4317
|
+
const refs = (op.itemIds || []).map((ref) => ref?.startsWith('$') ? `__ref(${ref.substring(1)})` : `'${ref}'`);
|
|
3994
4318
|
const idsArrayExpr = `[${refs.join(', ')}]`;
|
|
3995
4319
|
const nameExpr = op.groupName !== undefined ? JSON.stringify(op.groupName) : 'undefined';
|
|
3996
4320
|
// Returns { groupId, ... } (no itemId) so it does NOT shift $N indices — like relation.
|
|
@@ -4000,7 +4324,7 @@ return (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
|
|
|
4000
4324
|
`;
|
|
4001
4325
|
}
|
|
4002
4326
|
case 'set_background':
|
|
4003
|
-
const bgColor = op.backgroundColor || op.properties?.color || '#000000';
|
|
4327
|
+
const bgColor = op.backgroundColor || op.color || op.properties?.backgroundColor || op.properties?.color || '#000000';
|
|
4004
4328
|
return `
|
|
4005
4329
|
app.setBackgroundColor('${bgColor}');
|
|
4006
4330
|
return { success: true, backgroundColor: '${bgColor}' };
|
|
@@ -4034,28 +4358,54 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
|
|
|
4034
4358
|
return `
|
|
4035
4359
|
// Keyframes merged into operation ${foldTarget} (same target). A keyframe track
|
|
4036
4360
|
// is replaced wholesale, so emitting both here would silently drop one.
|
|
4037
|
-
return { itemId: ${op.itemId?.startsWith('$') ? `
|
|
4361
|
+
return { itemId: ${op.itemId?.startsWith('$') ? `__ref(${op.itemId.substring(1)})` : `'${op.itemId}'`}, merged: true, mergedInto: ${foldTarget} };
|
|
4038
4362
|
`;
|
|
4039
4363
|
}
|
|
4040
4364
|
const mergedPayload = mergePlan?.merged.get(index);
|
|
4041
|
-
const
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4365
|
+
const rawItemId = String(op.itemId ?? op.id ?? op.properties?.itemId ?? op.properties?.id);
|
|
4366
|
+
const kfItemRef = rawItemId?.startsWith('$')
|
|
4367
|
+
? `__ref(${rawItemId.substring(1)})`
|
|
4368
|
+
: `'${rawItemId}'`;
|
|
4369
|
+
const kfSource = mergedPayload
|
|
4370
|
+
? mergedPayload.keyframes
|
|
4371
|
+
: (op.keyframes || (op.properties && op.properties.keyframes) || (op.params && op.params.keyframes) || []);
|
|
4045
4372
|
const kfJson = JSON.stringify(normalizeKeyframePositions(kfSource));
|
|
4046
4373
|
const kfDuration = mergedPayload
|
|
4047
4374
|
? mergedPayload.duration
|
|
4048
|
-
: (op.duration ||
|
|
4049
|
-
const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? false);
|
|
4375
|
+
: (op.duration || op.properties?.duration || (kfSource.length ? Math.max(...kfSource.map((k) => Number(k.time) || 0)) : 5));
|
|
4376
|
+
const kfLoop = mergedPayload ? mergedPayload.loop : (op.loop ?? op.properties?.loop ?? false);
|
|
4377
|
+
// Same contract as pinepaper_keyframe_animate: replace unless append.
|
|
4378
|
+
const kfAppend = op.append === true || op.properties?.append === true;
|
|
4379
|
+
// Batch ops are passthrough, so these arrive unvalidated: numbers only, and timeUnits from its enum.
|
|
4380
|
+
const kfExtra = [];
|
|
4381
|
+
for (const key of ['timeOffset', 'clipInPoint', 'clipOutPoint']) {
|
|
4382
|
+
const v = Number(op[key]);
|
|
4383
|
+
if (op[key] !== undefined && Number.isFinite(v))
|
|
4384
|
+
kfExtra.push(`${key}: ${v}`);
|
|
4385
|
+
}
|
|
4386
|
+
const kfUnits = op.timeUnits;
|
|
4387
|
+
if (kfUnits === 'seconds' || kfUnits === 'ms')
|
|
4388
|
+
kfExtra.push(`timeUnits: '${kfUnits}'`);
|
|
4389
|
+
const kfExtraStr = kfExtra.length > 0 ? `, ${kfExtra.join(', ')}` : '';
|
|
4390
|
+
const kfUnsupported = [...new Set((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})))]
|
|
4391
|
+
.filter((k) => k === 'letterSpacing');
|
|
4392
|
+
const kfIgnored = kfUnsupported.length === 0 ? '' : `, ignoredProperties: ${JSON.stringify(kfUnsupported)}, warning: ${JSON.stringify(`${kfUnsupported.join(', ')} is not an animatable property in this engine (app.listAnimatableProperties does not list it), so it had no effect. Set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX.`)}`;
|
|
4050
4393
|
return `
|
|
4051
4394
|
const targetId = ${kfItemRef};
|
|
4052
|
-
|
|
4053
|
-
|
|
4395
|
+
const _target = (app.getItemById && app.getItemById(targetId))
|
|
4396
|
+
|| (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(targetId));
|
|
4397
|
+
if (!_target) {
|
|
4398
|
+
return { success: false, itemId: targetId, error: 'Item not found: ' + targetId };
|
|
4399
|
+
}
|
|
4400
|
+
const _kfItem = _target.item || _target;
|
|
4401
|
+
const _prevKeys = (_kfItem.data && Array.isArray(_kfItem.data.keyframes)) ? _kfItem.data.keyframes.length : 0;
|
|
4402
|
+
app.addAnimation(targetId, ${kfJson}, { duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ", mode: 'merge'" : ''}${kfExtraStr} });
|
|
4403
|
+
return { success: true, itemId: targetId, duration: ${kfDuration}, loop: ${kfLoop}${kfAppend ? ', append: true' : ''}${kfAppend ? '' : `, ...(_prevKeys ? { track: { replaced: true, previousKeys: _prevKeys, note: 'the item already had a keyframe track; this op replaced it. To add keys to it instead, pass append: true.' } } : {})`}${kfIgnored} };
|
|
4054
4404
|
`;
|
|
4055
4405
|
}
|
|
4056
4406
|
case 'apply_mask': {
|
|
4057
4407
|
const maskItemRef = op.itemId?.startsWith('$')
|
|
4058
|
-
? `
|
|
4408
|
+
? `__ref(${op.itemId.substring(1)})`
|
|
4059
4409
|
: `'${op.itemId}'`;
|
|
4060
4410
|
const maskOpts = JSON.stringify(op.maskOptions || {});
|
|
4061
4411
|
const preset = op.maskPreset || op.maskType || 'wipeLeft';
|
|
@@ -4074,7 +4424,7 @@ return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
|
|
|
4074
4424
|
}
|
|
4075
4425
|
case 'apply_effect': {
|
|
4076
4426
|
const effItemRef = op.itemId?.startsWith('$')
|
|
4077
|
-
? `
|
|
4427
|
+
? `__ref(${op.itemId.substring(1)})`
|
|
4078
4428
|
: `'${op.itemId}'`;
|
|
4079
4429
|
const effParams = JSON.stringify(op.effectParams || {});
|
|
4080
4430
|
return `
|
|
@@ -4459,34 +4809,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
4459
4809
|
// warnings:[] plus a note when it checked and found nothing, and the
|
|
4460
4810
|
// warnings themselves otherwise. The note carries the caveat that absence
|
|
4461
4811
|
// was standing in for.
|
|
4462
|
-
|
|
4463
|
-
// STAND-INS ARE THIS TOOL'S OWN DOING, SO THIS TOOL REPORTS THEM. create_item
|
|
4464
|
-
// draws 'shader' and 'field' locally as a flat plate tagged data.renderAs; the
|
|
4465
|
-
// engine's check sees an ordinary rectangle and cannot know a surface was
|
|
4466
|
-
// meant. So a local export of a sea came out a flat blue slab with nothing
|
|
4467
|
-
// said. Reported whatever the engine check can or cannot do.
|
|
4468
|
-
function standIns() {
|
|
4469
|
-
try {
|
|
4470
|
-
// Probed with === : a studio without it degrades to "no stand-ins".
|
|
4471
|
-
const canList = app.itemRegistry && typeof app.itemRegistry.getAll === 'function';
|
|
4472
|
-
if (!canList) return [];
|
|
4473
|
-
const found = {};
|
|
4474
|
-
for (const entry of app.itemRegistry.getAll()) {
|
|
4475
|
-
const kind = entry && entry.item && entry.item.data && entry.item.data.renderAs;
|
|
4476
|
-
if (kind === 'shader' || kind === 'field') (found[kind] = found[kind] || []).push(entry.itemId);
|
|
4477
|
-
}
|
|
4478
|
-
return Object.keys(found).map(function(kind) {
|
|
4479
|
-
const ids = found[kind];
|
|
4480
|
-
return {
|
|
4481
|
-
code: 'render_time_surface_stand_in',
|
|
4482
|
-
message: ids.length + ' ' + kind + ' item' + (ids.length > 1 ? 's are' : ' is')
|
|
4483
|
-
+ ' exported as the flat plate that stands in for ' + (ids.length > 1 ? 'them' : 'it')
|
|
4484
|
-
+ ' on a local canvas. The surface itself is drawn only by a cloud render.',
|
|
4485
|
-
items: ids.slice(0, 6),
|
|
4486
|
-
};
|
|
4487
|
-
});
|
|
4488
|
-
} catch (e) { return []; }
|
|
4489
|
-
}
|
|
4812
|
+
|
|
4490
4813
|
// TRANSPARENCY THE FORMAT CANNOT CARRY. A scene with no background exported
|
|
4491
4814
|
// to webm came out on BLACK while the engine's check said it lost nothing
|
|
4492
4815
|
// (round 7, 8.12). Only said when the scene is evidently transparent — no
|
|
@@ -4525,7 +4848,7 @@ ${usesCanvasSize ? ` // 'auto': the canvas's own size, with the preset only as
|
|
|
4525
4848
|
} catch (e) { return []; }
|
|
4526
4849
|
}
|
|
4527
4850
|
function fidelity(fmt) {
|
|
4528
|
-
const own =
|
|
4851
|
+
const own = alphaLoss(fmt).concat(textOverflow());
|
|
4529
4852
|
try {
|
|
4530
4853
|
if (!app.exportEngine || typeof app.exportEngine.exportFidelity !== 'function') {
|
|
4531
4854
|
return { fidelity: { available: false, reason: 'this studio cannot check export fidelity — update PinePaper Studio.', ...(own.length ? { warnings: own } : {}) } };
|
|
@@ -5481,6 +5804,34 @@ ${(() => {
|
|
|
5481
5804
|
})()}
|
|
5482
5805
|
${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
|
|
5483
5806
|
if (result && result.success) result.time = ${stillTime};` : ''}
|
|
5807
|
+
// SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the
|
|
5808
|
+
// camera's frame map, so a HUD item is placed as if the camera had not moved.
|
|
5809
|
+
if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(${JSON.stringify(format)}) !== -1) {
|
|
5810
|
+
try {
|
|
5811
|
+
const __hud = app.screenSpaceItems() || [];
|
|
5812
|
+
if (__hud.length) {
|
|
5813
|
+
result.fidelity = result.fidelity || { warnings: [] };
|
|
5814
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'screen_space_ignored', message: __hud.length + ' screen-space (HUD) item(s) are written at their frame coordinates in world space: ${format} has no camera frame, so they do not stay fixed under the camera move. Export mp4, webm, gif or png to keep them fixed.' }]);
|
|
5815
|
+
}
|
|
5816
|
+
} catch (_) { /* the export stands */ }
|
|
5817
|
+
}
|
|
5818
|
+
// WHICH STUDIO MADE THIS FILE (D92): a service worker can serve a stale
|
|
5819
|
+
// build right after a deploy, and a retest then judges old code. The
|
|
5820
|
+
// engine's own identity, version + build sha, rides on every export reply.
|
|
5821
|
+
try { if (result && typeof result === 'object' && app.buildId) result.studioBuild = app.buildId; } catch (_) { /* older studio */ }
|
|
5822
|
+
// A STALE STUDIO (D92): the file was made by an old build. Said in the
|
|
5823
|
+
// fidelity warnings the reply shows, not only as a field.
|
|
5824
|
+
try {
|
|
5825
|
+
const __bs = app.buildStatus ? app.buildStatus() : null;
|
|
5826
|
+
if (result && typeof result === 'object' && __bs) {
|
|
5827
|
+
result.liveBuild = __bs.liveBuildId;
|
|
5828
|
+
if (__bs.stale) {
|
|
5829
|
+
result.stale = true;
|
|
5830
|
+
result.fidelity = result.fidelity || { warnings: [] };
|
|
5831
|
+
result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'stale_build', message: 'this studio is running an old build (' + __bs.buildId + '; the live build is ' + __bs.liveBuildId + '): this file was made by the old code. Start a new job (pinepaper_agent_start_job, which reloads it) and export again.' }]);
|
|
5832
|
+
}
|
|
5833
|
+
}
|
|
5834
|
+
} catch (_) { /* older studio */ }
|
|
5484
5835
|
// RELIGHT (FxTool D5): applied to png / pdf / mp4 / webm, not gif / apng
|
|
5485
5836
|
// or the vector formats; and it is what makes a video export slow.
|
|
5486
5837
|
if (result && result.success && typeof app.getRelight === 'function') {
|
|
@@ -6792,7 +7143,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
|
|
|
6792
7143
|
if (typeof app.captureProjectDocument !== 'function') { return { success: false, error: 'app.captureProjectDocument unavailable — update FxTool' }; }
|
|
6793
7144
|
${SCENE_COUNTS_JS}
|
|
6794
7145
|
const doc = app.captureProjectDocument(${JSON.stringify(name ? { name } : {})});
|
|
6795
|
-
|
|
7146
|
+
// What the document CANNOT carry (D29): render hooks added as raw functions
|
|
7147
|
+
// and frame callbacks from generated code. Named, never dropped in silence.
|
|
7148
|
+
const __un = (doc && Array.isArray(doc.unserialized)) ? doc.unserialized
|
|
7149
|
+
: (app.listUnserializedCallbacks ? app.listUnserializedCallbacks() : []);
|
|
7150
|
+
return { success: true, json: JSON.stringify(doc), counts: __ppSceneCounts(), unserialized: __un };
|
|
6796
7151
|
})();`.trim();
|
|
6797
7152
|
}
|
|
6798
7153
|
/** Load a staged project document (window.__ppStage.projectDoc), replacing the scene. */
|
|
@@ -7709,13 +8064,22 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
|
|
|
7709
8064
|
opts.push(`defaultTransition: ${JSON.stringify(input.defaultTransition)}`);
|
|
7710
8065
|
if (input.transitionDuration !== undefined)
|
|
7711
8066
|
opts.push(`transitionDuration: ${input.transitionDuration * 1000}`);
|
|
8067
|
+
if (input.cutSound !== undefined)
|
|
8068
|
+
opts.push(`cutSound: ${JSON.stringify(input.cutSound)}`);
|
|
7712
8069
|
const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : '';
|
|
7713
8070
|
return `
|
|
7714
8071
|
// Create scene chain for sequential playback
|
|
7715
8072
|
(function() {
|
|
7716
8073
|
${guard}
|
|
7717
|
-
app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
|
|
7718
|
-
|
|
8074
|
+
const __cr = app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
|
|
8075
|
+
// An unknown cut sound comes back {ok:false, reason} (FxTool D85); a
|
|
8076
|
+
// studio without cut sounds ignores the option, which the cuts readback shows.
|
|
8077
|
+
if (__cr && __cr.ok === false) { return { success: false, action: 'create_chain', error: __cr.reason || 'the chain was refused' }; }
|
|
8078
|
+
const __res = { success: true, action: 'create_chain', sceneIds: ${JSON.stringify(input.sceneIds)}${input.loop !== undefined ? `, loop: ${input.loop}` : ''}${input.defaultDuration !== undefined ? `, defaultDuration: ${input.defaultDuration}` : ''} };
|
|
8079
|
+
${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.sceneManager.getChainCuts ? app.sceneManager.getChainCuts() : null);
|
|
8080
|
+
if (__cuts) __res.cuts = __cuts;
|
|
8081
|
+
else __res.warning = 'this studio placed no cut sounds: it predates them (FxTool D85). The chain plays without them.';
|
|
8082
|
+
` : ''} return __res;
|
|
7719
8083
|
})();
|
|
7720
8084
|
`.trim();
|
|
7721
8085
|
}
|
|
@@ -8143,6 +8507,32 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
|
|
|
8143
8507
|
})();`.trim();
|
|
8144
8508
|
}
|
|
8145
8509
|
generateBrandKit(input) {
|
|
8510
|
+
// THE ACTIVE BRAND (D69). apply recolours what is on the canvas once; a
|
|
8511
|
+
// later beat's new items knew nothing of it. 'set' applies AND makes the
|
|
8512
|
+
// kit the studio's active brand, which every create reads (withBrandDefaults).
|
|
8513
|
+
// It lives in the PAGE, not this process: one cloud server serves many
|
|
8514
|
+
// studios, and a brand must not cross between them.
|
|
8515
|
+
if (input.action === 'get' || input.action === 'clear') {
|
|
8516
|
+
return `
|
|
8517
|
+
// Brand kit: ${input.action} the active brand
|
|
8518
|
+
(function() {
|
|
8519
|
+
const b = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
|
|
8520
|
+
${input.action === 'clear' ? 'if (typeof window !== "undefined") window.__ppActiveBrand = null;' : ''}
|
|
8521
|
+
return { success: true, action: ${JSON.stringify(input.action)}, ${input.action === 'clear' ? 'cleared: !!b' : 'brand: b'} };
|
|
8522
|
+
})();`.trim();
|
|
8523
|
+
}
|
|
8524
|
+
if (input.action === 'set') {
|
|
8525
|
+
const applied = this.generateBrandKit({ ...input, action: 'apply' });
|
|
8526
|
+
return `
|
|
8527
|
+
// Brand kit: apply, then make it the active brand every new item inherits
|
|
8528
|
+
(async function() {
|
|
8529
|
+
const out = await ${applied.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
|
|
8530
|
+
if (out && (out.success === false || out.ok === false)) return out;
|
|
8531
|
+
if (typeof window !== 'undefined') window.__ppActiveBrand = ${JSON.stringify(input.kit)};
|
|
8532
|
+
return Object.assign({}, out, { success: true, action: 'set', active: true,
|
|
8533
|
+
note: 'new shapes take colors.primary and new text colors.text with the heading (32 px and up) or body font, wherever the call names none; noBrand: true opts an item out. The active brand lives with this studio session: set it again after a reload.' });
|
|
8534
|
+
})();`.trim();
|
|
8535
|
+
}
|
|
8146
8536
|
const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
|
|
8147
8537
|
const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
|
|
8148
8538
|
const facade = this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
|
|
@@ -8667,8 +9057,8 @@ ${resolveSource}
|
|
|
8667
9057
|
const resolve = ` const item = (typeof app._resolveRaster === 'function')
|
|
8668
9058
|
? app._resolveRaster(${JSON.stringify(input.itemId)})
|
|
8669
9059
|
: (app.itemRegistry ? app.itemRegistry.getItem(${JSON.stringify(input.itemId)}) : null);
|
|
8670
|
-
if (!item) return { error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
|
|
8671
|
-
if (typeof app.applyImageFilter !== 'function') return { error: 'GPU image filters unavailable — update FxTool' };`;
|
|
9060
|
+
if (!item) return { success: false, error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
|
|
9061
|
+
if (typeof app.applyImageFilter !== 'function') return { success: false, error: 'GPU image filters unavailable — update FxTool' };`;
|
|
8672
9062
|
switch (input.action) {
|
|
8673
9063
|
case 'apply':
|
|
8674
9064
|
return `
|
|
@@ -8685,7 +9075,7 @@ ${resolve}
|
|
|
8685
9075
|
}
|
|
8686
9076
|
return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
|
|
8687
9077
|
} catch (e) {
|
|
8688
|
-
return { error: 'Failed to apply filter: ' + e.message };
|
|
9078
|
+
return { success: false, action: 'apply', error: 'Failed to apply filter: ' + e.message };
|
|
8689
9079
|
}
|
|
8690
9080
|
})();`.trim();
|
|
8691
9081
|
case 'chain':
|
|
@@ -8698,7 +9088,7 @@ ${resolve}
|
|
|
8698
9088
|
await app.applyImageFilterChain(item, filters);
|
|
8699
9089
|
return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
|
|
8700
9090
|
} catch (e) {
|
|
8701
|
-
return { error: 'Failed to apply filter chain: ' + e.message };
|
|
9091
|
+
return { success: false, action: 'chain', error: 'Failed to apply filter chain: ' + e.message };
|
|
8702
9092
|
}
|
|
8703
9093
|
})();`.trim();
|
|
8704
9094
|
default:
|
|
@@ -9380,6 +9770,181 @@ ${resolve}
|
|
|
9380
9770
|
}
|
|
9381
9771
|
}
|
|
9382
9772
|
// ===========================================================================
|
|
9773
|
+
// HAND-DRAWN SKETCHY ANIMATION & JITTER
|
|
9774
|
+
// ===========================================================================
|
|
9775
|
+
generateHandDrawn(input) {
|
|
9776
|
+
const S = (v) => JSON.stringify(v);
|
|
9777
|
+
// style carries no schema default: with one, `mode` (its alias) was never read.
|
|
9778
|
+
const requested = input.style ?? input.mode ?? 'sketchy';
|
|
9779
|
+
const style = requested === 'ink_reveal' ? 'reveal' : requested;
|
|
9780
|
+
const roughness = input.roughness ?? 2;
|
|
9781
|
+
const bowing = input.bowing ?? 1;
|
|
9782
|
+
const fps = input.fps ?? input.posterizeFps ?? 12;
|
|
9783
|
+
const seed = input.seed ?? 1;
|
|
9784
|
+
const duration = input.duration ?? 2;
|
|
9785
|
+
// WOBBLE AND STOP_MOTION ANSWERED SUCCESS AND EXPORTED NOTHING. The engine's
|
|
9786
|
+
// wobble draws one frame per call (updateHandDrawnWobble(item, frameIndex)),
|
|
9787
|
+
// so a single call is a static jitter; stop_motion is a per-frame timing
|
|
9788
|
+
// helper with no item to apply to, and applyHandDrawnStyle throws for it by
|
|
9789
|
+
// name. The fallback hung an onFrame on the path, which is neither saved
|
|
9790
|
+
// nor exported. The boiling line the studio does save and export is the ink
|
|
9791
|
+
// medium, so the refusal hands over that call with these arguments.
|
|
9792
|
+
if (style === 'wobble' || style === 'stop_motion') {
|
|
9793
|
+
const use = {
|
|
9794
|
+
tool: 'pinepaper_design_medium',
|
|
9795
|
+
args: { action: 'apply', itemId: input.itemId ?? '<itemId>', medium: 'ink', options: { keepColor: true, fps, seed, amplitude: roughness } },
|
|
9796
|
+
};
|
|
9797
|
+
const error = `'${style}' has no animated form this studio saves or exports, so it would have shown in the editor and not in a video. Nothing was changed. For a hand-drawn line that boils frame to frame in exports, call pinepaper_design_medium with the arguments in \`use\` (fps is the boil rate). For a one-off rough line, use style 'sketchy'.`;
|
|
9798
|
+
return `(function() { return { success: false, style: ${S(style)}, error: ${S(error)}, use: ${S(use)} }; })();`;
|
|
9799
|
+
}
|
|
9800
|
+
// REVEAL WAS AN onFrame DASH ANIMATION, which no export plays. A draw-on the
|
|
9801
|
+
// studio saves is a trimEnd 0 -> 1 keyframe track, merged so the item's
|
|
9802
|
+
// other keyframes survive.
|
|
9803
|
+
if (style === 'reveal') {
|
|
9804
|
+
if (!input.itemId) {
|
|
9805
|
+
return `(function() { return { success: false, style: 'reveal', error: 'reveal needs an itemId: it keyframes that item\\'s stroke. Nothing was changed.' }; })();`;
|
|
9806
|
+
}
|
|
9807
|
+
// A trimEnd track on text or an image is kept and draws nothing (measured
|
|
9808
|
+
// on prod: a PointText answered success and never changed), so the item
|
|
9809
|
+
// is checked for a stroke first.
|
|
9810
|
+
const track = generateKeyframeAnimateCode(input.itemId, [
|
|
9811
|
+
{ time: 0, properties: { trimEnd: 0 }, easing: 'linear' },
|
|
9812
|
+
{ time: duration, properties: { trimEnd: 1 }, easing: 'linear' },
|
|
9813
|
+
], duration, false, undefined, undefined, undefined, undefined, true).replace(/;\s*$/, '');
|
|
9814
|
+
return `
|
|
9815
|
+
// Hand-drawn draw-on: a trimEnd track on a stroked path
|
|
9816
|
+
(function() {
|
|
9817
|
+
const found = app.getItemById(${S(input.itemId)}) || (app.itemRegistry && app.itemRegistry.get(${S(input.itemId)}));
|
|
9818
|
+
const it = found && found.item ? found.item : found;
|
|
9819
|
+
const isPath = function(p) { return !!(p && p.segments && p.segments.length); };
|
|
9820
|
+
if (it && !isPath(it) && !(it.children && it.children.some(isPath))) {
|
|
9821
|
+
return { success: false, itemId: ${S(input.itemId)}, error: 'reveal draws a stroke on, and this item has no path to draw (' + (it.className || 'not a path') + '). Nothing was changed. For words that write themselves on, use pinepaper_text_style cursive with animate: true.' };
|
|
9822
|
+
}
|
|
9823
|
+
return (
|
|
9824
|
+
${track}
|
|
9825
|
+
);
|
|
9826
|
+
})();`.trim();
|
|
9827
|
+
}
|
|
9828
|
+
const targetId = input.itemId ? S(input.itemId) : 'null';
|
|
9829
|
+
return `
|
|
9830
|
+
// Hand-drawn sketchy stroke jitter
|
|
9831
|
+
(function() {
|
|
9832
|
+
const targetId = ${targetId};
|
|
9833
|
+
const found = targetId ? (app.getItemById(targetId) || (app.itemRegistry && app.itemRegistry.get(targetId))) : (app.selectedItems && app.selectedItems[0]);
|
|
9834
|
+
const item = found && found.item ? found.item : found;
|
|
9835
|
+
if (!item) {
|
|
9836
|
+
return { success: false, error: 'Target item not found for hand-drawn styling' };
|
|
9837
|
+
}
|
|
9838
|
+
// The registry id, which every other tool takes — not Paper's numeric item.id.
|
|
9839
|
+
const itemId = targetId || (item.data && (item.data.registryId || item.data.id)) || null;
|
|
9840
|
+
|
|
9841
|
+
// 1. The engine's own sketchy jitter.
|
|
9842
|
+
if (typeof HandDrawn !== 'undefined' && HandDrawn && typeof HandDrawn.applyHandDrawnStyle === 'function') {
|
|
9843
|
+
try {
|
|
9844
|
+
HandDrawn.applyHandDrawnStyle(item, 'sketchy', { roughness: ${roughness}, bowing: ${bowing}, seed: ${seed} });
|
|
9845
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
9846
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
9847
|
+
return { success: true, itemId: itemId, style: 'sketchy', engine: 'HandDrawn' };
|
|
9848
|
+
} catch (e) {
|
|
9849
|
+
console.warn('[HandDrawn] Engine error, falling back to procedural jitter:', e);
|
|
9850
|
+
}
|
|
9851
|
+
}
|
|
9852
|
+
|
|
9853
|
+
if (typeof app.applyHandDrawnJitter === 'function') {
|
|
9854
|
+
app.applyHandDrawnJitter(item, { roughness: ${roughness}, bowing: ${bowing}, seed: ${seed} });
|
|
9855
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
9856
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
9857
|
+
return { success: true, itemId: itemId, style: 'sketchy', engine: 'app.applyHandDrawnJitter' };
|
|
9858
|
+
}
|
|
9859
|
+
|
|
9860
|
+
// 2. Procedural mulberry32 PRNG vertex jitter fallback
|
|
9861
|
+
function mulberry32(a) {
|
|
9862
|
+
return function() {
|
|
9863
|
+
var t = a += 0x6D2B79F5;
|
|
9864
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
9865
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
9866
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
9867
|
+
};
|
|
9868
|
+
}
|
|
9869
|
+
|
|
9870
|
+
const targets = (item.children && item.children.length) ? item.children : [item];
|
|
9871
|
+
let moved = 0;
|
|
9872
|
+
targets.forEach((t, itemIdx) => {
|
|
9873
|
+
if (!t.segments || !t.segments.length) return;
|
|
9874
|
+
const rand = mulberry32(${seed} + itemIdx * 777);
|
|
9875
|
+
for (let i = 0; i < t.segments.length; i++) {
|
|
9876
|
+
const seg = t.segments[i];
|
|
9877
|
+
seg.point.x += (rand() - 0.5) * ${roughness} * 2;
|
|
9878
|
+
seg.point.y += (rand() - 0.5) * ${roughness} * 2;
|
|
9879
|
+
if (seg.handleIn && (seg.handleIn.x !== 0 || seg.handleIn.y !== 0)) {
|
|
9880
|
+
seg.handleIn.x += (rand() - 0.5) * ${bowing} * 1.5;
|
|
9881
|
+
seg.handleIn.y += (rand() - 0.5) * ${bowing} * 1.5;
|
|
9882
|
+
}
|
|
9883
|
+
if (seg.handleOut && (seg.handleOut.x !== 0 || seg.handleOut.y !== 0)) {
|
|
9884
|
+
seg.handleOut.x += (rand() - 0.5) * ${bowing} * 1.5;
|
|
9885
|
+
seg.handleOut.y += (rand() - 0.5) * ${bowing} * 1.5;
|
|
9886
|
+
}
|
|
9887
|
+
moved++;
|
|
9888
|
+
}
|
|
9889
|
+
});
|
|
9890
|
+
if (!moved) {
|
|
9891
|
+
return { success: false, itemId: itemId, error: 'sketchy moves path points, and this item has none (text or an image?). Nothing was changed.' };
|
|
9892
|
+
}
|
|
9893
|
+
|
|
9894
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
9895
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
9896
|
+
return { success: true, itemId: itemId, style: 'sketchy', roughness: ${roughness}, points: moved };
|
|
9897
|
+
})();`.trim();
|
|
9898
|
+
}
|
|
9899
|
+
// ===========================================================================
|
|
9900
|
+
// PEN FIGURES
|
|
9901
|
+
// ===========================================================================
|
|
9902
|
+
generatePenFigure(input) {
|
|
9903
|
+
const S = (v) => JSON.stringify(v);
|
|
9904
|
+
const missing = `if (typeof app.listFigures !== 'function' || typeof app.createFigure !== 'function') {
|
|
9905
|
+
return { success: false, error: 'This studio has no pen figures (app.createFigure). Nothing was drawn.' };
|
|
9906
|
+
}`;
|
|
9907
|
+
if (input.action === 'list') {
|
|
9908
|
+
return `
|
|
9909
|
+
// The pen figure catalogue
|
|
9910
|
+
(function() {
|
|
9911
|
+
${missing}
|
|
9912
|
+
const figures = app.listFigures();
|
|
9913
|
+
return { success: true, action: 'list', count: figures.length, figures: figures };
|
|
9914
|
+
})();`.trim();
|
|
9915
|
+
}
|
|
9916
|
+
if (input.figure === undefined) {
|
|
9917
|
+
return `(function() { return { success: false, error: "figure is required: a catalogue id (action 'list' names them) or an inline figure spec. Nothing was drawn." }; })();`;
|
|
9918
|
+
}
|
|
9919
|
+
const opts = {};
|
|
9920
|
+
for (const k of ['box', 'margin', 'duration', 'delay', 'loop', 'pen', 'strokeColor', 'penColor', 'strokeWidth']) {
|
|
9921
|
+
if (input[k] !== undefined)
|
|
9922
|
+
opts[k] = input[k];
|
|
9923
|
+
}
|
|
9924
|
+
// createFigure answers null for an unknown id or a bad spec, and says why
|
|
9925
|
+
// only in a console.warn, which production strips. The refusal is built
|
|
9926
|
+
// here instead, naming the catalogue.
|
|
9927
|
+
return `
|
|
9928
|
+
// Draw a figure with a pen (draws_figure)
|
|
9929
|
+
(function() {
|
|
9930
|
+
${missing}
|
|
9931
|
+
const figure = ${S(input.figure)};
|
|
9932
|
+
const out = app.createFigure(figure, ${S(opts)});
|
|
9933
|
+
if (!out || !out.ink) {
|
|
9934
|
+
const known = app.listFigures().map(function(f) { return f.id; });
|
|
9935
|
+
return { success: false, error: (typeof figure === 'string' && known.indexOf(figure) < 0
|
|
9936
|
+
? 'No figure "' + figure + '". Known: ' + known.join(', ') + '.'
|
|
9937
|
+
: 'The figure spec could not be drawn (it produced no strokes or did not parse).') + ' Nothing was drawn.' };
|
|
9938
|
+
}
|
|
9939
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
9940
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
9941
|
+
return out.pen
|
|
9942
|
+
? { success: true, inkId: out.ink, penId: out.pen, title: out.title, strokes: out.strokes, duration: ${S(input.duration ?? 6)},
|
|
9943
|
+
note: 'The pen draws the ink over duration seconds (relation draws_figure, pen → ink).' }
|
|
9944
|
+
: { success: true, inkId: out.ink, penId: null, title: out.title, strokes: out.strokes, note: 'pen: false — the ink is fully drawn, with no pen and no animation.' };
|
|
9945
|
+
})();`.trim();
|
|
9946
|
+
}
|
|
9947
|
+
// ===========================================================================
|
|
9383
9948
|
// SPRITE SHEETS
|
|
9384
9949
|
// ===========================================================================
|
|
9385
9950
|
generateSpriteSheet(input) {
|
|
@@ -9753,8 +10318,9 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
|
|
|
9753
10318
|
// Add physics body
|
|
9754
10319
|
(function() {
|
|
9755
10320
|
${guard}
|
|
9756
|
-
const entry = app.itemRegistry.get(${itemIdStr});
|
|
9757
|
-
|
|
10321
|
+
const entry = (app.getItemById && app.getItemById(${itemIdStr})) || (app.itemRegistry && (app.itemRegistry.getItem ? app.itemRegistry.getItem(${itemIdStr}) : app.itemRegistry.get(${itemIdStr})));
|
|
10322
|
+
const it = entry && (entry.item || entry);
|
|
10323
|
+
if (!it) return { error: 'Item not found: ' + ${itemIdStr} };
|
|
9758
10324
|
const ok = app.physicsWorld.addBody(${itemIdStr}, ${JSON.stringify(config)});
|
|
9759
10325
|
return { success: ok, action: 'add_body', itemId: ${itemIdStr}, bodyType: ${JSON.stringify(input.bodyType || 'dynamic')} };
|
|
9760
10326
|
})();`.trim();
|
|
@@ -11006,6 +11572,443 @@ ${guard}
|
|
|
11006
11572
|
* costs ~0.42 s per 1080p frame on the CPU, so a set says what an export
|
|
11007
11573
|
* will pay.
|
|
11008
11574
|
*/
|
|
11575
|
+
/**
|
|
11576
|
+
* pinepaper_choreograph (D67). The compiler runs IN THE STUDIO, serialised
|
|
11577
|
+
* by toString, because the track depends on the actor's live size, scale,
|
|
11578
|
+
* rotation and the export frame. Merged into the actor's track, read back
|
|
11579
|
+
* the way keyframe_animate reads its own.
|
|
11580
|
+
*/
|
|
11581
|
+
generateChoreograph(input) {
|
|
11582
|
+
const S = (v) => JSON.stringify(v);
|
|
11583
|
+
const id = S(input.itemId);
|
|
11584
|
+
return `
|
|
11585
|
+
// Choreograph ${input.beats.length} beat(s) for ${input.itemId}
|
|
11586
|
+
(function() {
|
|
11587
|
+
const __it = (app.getItemById && app.getItemById(${id}))
|
|
11588
|
+
|| (app.itemRegistry && app.itemRegistry.get && (app.itemRegistry.get(${id}) || {}).item);
|
|
11589
|
+
if (!__it || !__it.bounds) { return { success: false, error: ${id} + ' is not on the canvas — check the id with pinepaper_get_items.' }; }
|
|
11590
|
+
const __sc = __it.scaling || { x: 1, y: 1 };
|
|
11591
|
+
const __b = __it.bounds;
|
|
11592
|
+
const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
|
|
11593
|
+
${authoringFrameJs('__fr')}
|
|
11594
|
+
const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
|
|
11595
|
+
const __actor = { x: __it.position.x, y: __it.position.y,
|
|
11596
|
+
w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
|
|
11597
|
+
sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked };
|
|
11598
|
+
const __r = (${compileChoreography.toString()})(__actor, __frame, ${S(input.beats)});
|
|
11599
|
+
if (__r.error) { return { success: false, itemId: ${id}, error: __r.error }; }
|
|
11600
|
+
const __prev = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes : [];
|
|
11601
|
+
const __prevTimes = __prev.map(function (k) { return k && k.time; });
|
|
11602
|
+
const __ours = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'opacity'];
|
|
11603
|
+
const __other = __prev.reduce(function (acc, k) { Object.keys((k && k.properties) || {}).forEach(function (p) { if (__ours.indexOf(p) < 0 && acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
|
|
11604
|
+
app.addAnimation(${id}, __r.keyframes, { duration: Math.max(__r.end, __prev.reduce(function (m, k) { return Math.max(m, (k && k.time) || 0); }, 0)), loop: false, mode: 'merge' });
|
|
11605
|
+
const __now = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes.map(function (k) { return k && k.time; }) : [];
|
|
11606
|
+
const __warnings = (__r.warnings || []).slice();
|
|
11607
|
+
if (__prevTimes.length && !__prevTimes.every(function (t) { return __now.indexOf(t) >= 0; })) {
|
|
11608
|
+
__warnings.push('this studio replaced the existing track of the actor (' + __prevTimes.length + ' key(s)) instead of merging: put the other keys in after choreographing.');
|
|
11609
|
+
}
|
|
11610
|
+
if (__other.length) {
|
|
11611
|
+
__warnings.push('the actor already animates ' + __other.join(', ') + '; the beat keys leave those out, which the engine can break at the beat keys. Re-key ' + __other.join(', ') + ' with every key carrying them, after choreographing.');
|
|
11612
|
+
}
|
|
11613
|
+
if (__r.final && !__r.final.onScreen) __warnings.push('after the last beat the actor is off screen at [' + __r.final.x + ', ' + __r.final.y + '].');
|
|
11614
|
+
if (__fr.unbounded && __warnings.some(function (w) { return /off screen/.test(w); })) __warnings.push('the canvas is unbounded, so it has no fixed frame: on-screen was checked against its nominal ' + __fr.width + 'x' + __fr.height + ' artboard. Set a canvas size or preset to fix the frame.');
|
|
11615
|
+
const __res = { success: true, itemId: ${id}, beats: ${input.beats.length}, keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };
|
|
11616
|
+
${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —
|
|
11617
|
+
// pop, launch, contact, shake — loud as the moment is strong. A sound that
|
|
11618
|
+
// does not place is reported; it never undoes the motion.
|
|
11619
|
+
const __map = Object.assign({ pop: 'pop', whoosh: 'whoosh', contact: 'tom', shake: 'hat' }, ${S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})});
|
|
11620
|
+
const __gain = ${S(typeof input.sound === 'object' && input.sound.gain !== undefined ? input.sound.gain : 0.8)};
|
|
11621
|
+
const __sounds = [];
|
|
11622
|
+
const __silent = [];
|
|
11623
|
+
if (typeof app.createSound !== 'function' || (typeof app.sfxSpec !== 'function' && typeof app.percussionSpec !== 'function')) {
|
|
11624
|
+
__warnings.push('no sound was placed: this studio cannot place synthesized sounds (app.createSound / sfxSpec). The motion is written.');
|
|
11625
|
+
} else {
|
|
11626
|
+
(__r.events || []).forEach(function (e) {
|
|
11627
|
+
const name = __map[e.kind];
|
|
11628
|
+
if (name === false || name === undefined) return;
|
|
11629
|
+
const base = (typeof app.sfxSpec === 'function' && app.sfxSpec(name)) || (typeof app.percussionSpec === 'function' && app.percussionSpec(name));
|
|
11630
|
+
if (!base) { if (__silent.indexOf(name) < 0) __silent.push(name); return; }
|
|
11631
|
+
const spec = Object.assign({}, base, { gain: Math.round(((base.gain || 0.8) * __gain * (0.35 + 0.65 * e.strength)) * 1000) / 1000 });
|
|
11632
|
+
// MP4 (AAC) encoder priming swallows the first ~50 ms of audio, and this
|
|
11633
|
+
// muxer writes no edit list (the engine reports audio_lead_trimmed): a pop
|
|
11634
|
+
// at 0 s exported as silence (prod retest of D68). 60 ms later is in sync
|
|
11635
|
+
// to the eye and audible in every format.
|
|
11636
|
+
const at = e.time < 0.06 ? 0.06 : e.time;
|
|
11637
|
+
const it = app.createSound(spec, { startTime: at, duration: spec.duration || 0.5 });
|
|
11638
|
+
if (it && it.data) __sounds.push(Object.assign({ t: at, kind: e.kind, sound: name, itemId: it.data.id }, at !== e.time ? { movedFrom: e.time } : {}));
|
|
11639
|
+
});
|
|
11640
|
+
if (__silent.length) __warnings.push('not in the sound catalogue, so silent: ' + __silent.join(', ') + ' (pinepaper_sound list_sfx / list_percussion for the names).');
|
|
11641
|
+
}
|
|
11642
|
+
__res.sounds = __sounds;
|
|
11643
|
+
` : ''}
|
|
11644
|
+
if (__warnings.length) __res.warnings = __warnings;
|
|
11645
|
+
return __res;
|
|
11646
|
+
})();`.trim();
|
|
11647
|
+
}
|
|
11648
|
+
/**
|
|
11649
|
+
* pinepaper_morph_sequence (D73): ONE element through N states — R8's pill →
|
|
11650
|
+
* input → dot → grid → composer → orb. The engine already chains morphs_to
|
|
11651
|
+
* on one shape (per-relation state since gap 2.27); what a model could not
|
|
11652
|
+
* do was time the chain, make the reference shapes, and ride the camera on
|
|
11653
|
+
* the same curve. Compiled here to that chain, a camera track and sounds.
|
|
11654
|
+
*/
|
|
11655
|
+
generateMorphSequence(input) {
|
|
11656
|
+
const S = (v) => JSON.stringify(v);
|
|
11657
|
+
const states = [...input.states].sort((a, b) => a.at - b.at).map((st) => ({ at: st.at, duration: st.duration ?? 0.8, to: st.to ?? null, shape: st.shape ?? null }));
|
|
11658
|
+
const cam = input.camera === true ? { fill: 0.45 } : (input.camera || null);
|
|
11659
|
+
const sound = input.sound === true ? 'whoosh' : (input.sound || null);
|
|
11660
|
+
return `
|
|
11661
|
+
// Morph sequence: ${states.length} state(s)
|
|
11662
|
+
(function() {
|
|
11663
|
+
const src = app.getItemById && app.getItemById(${S(input.itemId)});
|
|
11664
|
+
if (!src) { return { success: false, error: ${S(input.itemId)} + ' is not on the canvas.' }; }
|
|
11665
|
+
if (typeof app.addRelation !== 'function') { return { success: false, error: 'this studio cannot add relations.' }; }
|
|
11666
|
+
const states = ${S(states)};
|
|
11667
|
+
const done = [];
|
|
11668
|
+
for (let i = 0; i < states.length; i++) {
|
|
11669
|
+
const st = states[i];
|
|
11670
|
+
let tid = st.to;
|
|
11671
|
+
if (!tid) {
|
|
11672
|
+
const p = Object.assign({}, st.shape.properties || {}, { x: st.shape.position.x, y: st.shape.position.y });
|
|
11673
|
+
const made = app.create(st.shape.itemType, p);
|
|
11674
|
+
if (made) { made.visible = false; }
|
|
11675
|
+
tid = made && made.data && made.data.registryId;
|
|
11676
|
+
if (!tid) { return { success: false, error: 'state ' + i + ': could not make the ' + st.shape.itemType + ' reference shape', states: done }; }
|
|
11677
|
+
} else if (!(app.getItemById && app.getItemById(tid))) {
|
|
11678
|
+
return { success: false, error: 'state ' + i + ': ' + tid + ' is not on the canvas', states: done };
|
|
11679
|
+
}
|
|
11680
|
+
const ok = app.addRelation(${S(input.itemId)}, tid, 'morphs_to', { duration: st.duration, delay: st.at, easing: ${S(input.easing ?? 'easeInOut')}, hideTarget: true });
|
|
11681
|
+
if (ok === false) { return { success: false, error: 'state ' + i + ': the studio refused the morph into ' + tid, states: done }; }
|
|
11682
|
+
done.push({ at: st.at, duration: st.duration, targetId: tid });
|
|
11683
|
+
}
|
|
11684
|
+
const res = { success: true, itemId: ${S(input.itemId)}, states: done, end: done[done.length - 1].at + done[done.length - 1].duration };
|
|
11685
|
+
const n = app.getRelations ? app.getRelations(${S(input.itemId)}, 'morphs_to').length : null;
|
|
11686
|
+
if (n !== null && n < done.length) res.warning = 'only ' + n + ' of ' + done.length + ' morphs are on the element.';
|
|
11687
|
+
${cam ? ` // The camera rides the SAME timing: framing each state as its morph lands.
|
|
11688
|
+
{
|
|
11689
|
+
${authoringFrameJs('fr')}
|
|
11690
|
+
const fill = ${S(cam.fill ?? 0.45)};
|
|
11691
|
+
const frameOf = function (it) { const b = it.bounds; return { focus: [b.center.x, b.center.y], zoom: Math.max(0.2, Math.min(8, fill * Math.min(fr.width / Math.max(1, b.width), fr.height / Math.max(1, b.height)))) }; };
|
|
11692
|
+
const f0 = frameOf(src);
|
|
11693
|
+
const kfs = [{ time: 0, zoom: f0.zoom, focus: f0.focus }];
|
|
11694
|
+
done.forEach(function (d) {
|
|
11695
|
+
const t = app.getItemById(d.targetId);
|
|
11696
|
+
const f = frameOf(t);
|
|
11697
|
+
const prev = kfs[kfs.length - 1];
|
|
11698
|
+
kfs.push({ time: d.at, zoom: prev.zoom, focus: prev.focus });
|
|
11699
|
+
kfs.push({ time: d.at + d.duration, zoom: f.zoom, focus: f.focus, easing: ${S(cam.easing ?? 'easeInOut')} });
|
|
11700
|
+
});
|
|
11701
|
+
// The SAME door pinepaper_camera_animate uses: app.camera.animate where the
|
|
11702
|
+
// build has it, else the camera_animates relation — prod has only the
|
|
11703
|
+
// relation, and probing app.camera.animate alone reported "no camera" there.
|
|
11704
|
+
const r = app.camera && app.camera.animate
|
|
11705
|
+
? app.camera.animate(kfs, res.end, false, 0, {})
|
|
11706
|
+
: app.addRelation('camera', 'camera', 'camera_animates', { keyframes: kfs, duration: res.end, loop: false, delay: 0 });
|
|
11707
|
+
// Read the track back where the engine keeps it.
|
|
11708
|
+
const rr = app.relationRegistry;
|
|
11709
|
+
const written = rr && rr.getCameraAnimationParams ? rr.getCameraAnimationParams() : null;
|
|
11710
|
+
if (r === false || (rr && rr.getCameraAnimationParams && !written)) {
|
|
11711
|
+
res.warning = (res.warning ? res.warning + ' ' : '') + 'the camera track was not written; the morphs run without it.';
|
|
11712
|
+
} else {
|
|
11713
|
+
res.camera = { keyframes: kfs.length };
|
|
11714
|
+
}
|
|
11715
|
+
}
|
|
11716
|
+
` : ''}${sound ? ` // A sound on each morph (D68's placement, with its MP4-lead nudge).
|
|
11717
|
+
const base = (app.sfxSpec && app.sfxSpec(${S(sound)})) || (app.percussionSpec && app.percussionSpec(${S(sound)}));
|
|
11718
|
+
if (base && app.createSound) {
|
|
11719
|
+
res.sounds = done.map(function (d) { const at = d.at < 0.06 ? 0.06 : d.at; const it = app.createSound(Object.assign({}, base), { startTime: at, duration: base.duration || 0.5 }); return { t: at, itemId: it && it.data && it.data.id }; });
|
|
11720
|
+
} else { res.warning = (res.warning ? res.warning + ' ' : '') + ${S(`no sound placed: ${sound} is not in the catalogue, or this studio cannot place sounds.`)}; }
|
|
11721
|
+
` : ''} return res;
|
|
11722
|
+
})();`.trim();
|
|
11723
|
+
}
|
|
11724
|
+
/**
|
|
11725
|
+
* pinepaper_original_character (D66): app.createCharacter and its schema
|
|
11726
|
+
* helpers. The helpers read the SAME table the validator uses, so a model
|
|
11727
|
+
* reads the legal values (describe) before it guesses; a wrong one is
|
|
11728
|
+
* refused by name and nothing is drawn.
|
|
11729
|
+
*/
|
|
11730
|
+
generateOriginalCharacter(input) {
|
|
11731
|
+
const S = (v) => JSON.stringify(v);
|
|
11732
|
+
const guard = `if (typeof app.createCharacter !== 'function') { return { success: false, error: 'this studio has no original characters (app.createCharacter) — it predates FxTool D66.' }; }`;
|
|
11733
|
+
switch (input.action) {
|
|
11734
|
+
case 'bases':
|
|
11735
|
+
return `(function() {\n ${guard}\n return { success: true, action: 'bases', bases: app.listCharacterBases() };\n})();`;
|
|
11736
|
+
case 'describe':
|
|
11737
|
+
return `(function() {\n ${guard}\n return { success: true, action: 'describe', schema: app.describeCharacterVariantSchema(${input.base ? S(input.base) : ''}) };\n})();`;
|
|
11738
|
+
case 'random':
|
|
11739
|
+
return `(function() {\n ${guard}\n const r = app.randomCharacterVariant(${S(input.base)}, ${S(input.seed)}${input.fixed ? `, { fixed: ${S(input.fixed)} }` : ''});\n if (!r || r.ok === false) return { success: false, action: 'random', error: (r && r.errors && r.errors.join('; ')) || 'no variant' };\n return { success: true, action: 'random', base: ${S(input.base)}, seed: ${S(input.seed)}, variant: r.variant };\n})();`;
|
|
11740
|
+
default: {
|
|
11741
|
+
const spec = { base: input.base, ...(input.variant ? { variant: input.variant } : {}), ...(input.at ? { x: input.at.x, y: input.at.y } : {}),
|
|
11742
|
+
...(input.height !== undefined ? { height: input.height } : {}), ...(input.scale !== undefined ? { scale: input.scale } : {}), ...(input.rig !== undefined ? { rig: input.rig } : {}) };
|
|
11743
|
+
return `
|
|
11744
|
+
// Original character: create
|
|
11745
|
+
(async function() {
|
|
11746
|
+
${guard}
|
|
11747
|
+
const r = await app.createCharacter(${S(spec)});
|
|
11748
|
+
if (!r || r.ok === false) return { success: false, action: 'create', errors: (r && r.errors) || [], error: (r && r.errors && r.errors.join('; ')) || 'the character was refused' };
|
|
11749
|
+
return { success: true, action: 'create', itemId: r.id, rigId: r.rigId, parts: r.parts, name: r.name, base: r.base, look: r.look, variant: r.variant };
|
|
11750
|
+
})();`.trim();
|
|
11751
|
+
}
|
|
11752
|
+
}
|
|
11753
|
+
}
|
|
11754
|
+
/**
|
|
11755
|
+
* Character tool aliases (pinepaper_create_character, pinepaper_character_action, pinepaper_character_pose)
|
|
11756
|
+
* Routing on-device / local model calls cleanly to the engine.
|
|
11757
|
+
*/
|
|
11758
|
+
generateCreateCharacter(input) {
|
|
11759
|
+
const S = (v) => JSON.stringify(v);
|
|
11760
|
+
const charId = S(input.characterId || input.name || `char_${Date.now()}`);
|
|
11761
|
+
const posX = typeof input.x === 'number' ? input.x : 400;
|
|
11762
|
+
const posY = typeof input.y === 'number' ? input.y : 300;
|
|
11763
|
+
const scale = typeof input.scale === 'number' ? input.scale : 1;
|
|
11764
|
+
const name = S(input.name || input.characterId || 'Character');
|
|
11765
|
+
const base = input.base ? S(input.base) : 'null';
|
|
11766
|
+
const preset = input.preset || input.concept ? S(input.preset || input.concept) : 'null';
|
|
11767
|
+
return `
|
|
11768
|
+
// Create Character
|
|
11769
|
+
(async function() {
|
|
11770
|
+
const charId = ${charId};
|
|
11771
|
+
const posX = ${posX};
|
|
11772
|
+
const posY = ${posY};
|
|
11773
|
+
const scale = ${scale};
|
|
11774
|
+
const name = ${name};
|
|
11775
|
+
const base = ${base};
|
|
11776
|
+
const preset = ${preset};
|
|
11777
|
+
|
|
11778
|
+
// 1. If original character base is specified and app.createCharacter exists
|
|
11779
|
+
if (base && typeof app.createCharacter === 'function') {
|
|
11780
|
+
const r = await app.createCharacter({ base: base, at: { x: posX, y: posY }, scale: scale });
|
|
11781
|
+
if (r && r.ok !== false) {
|
|
11782
|
+
return { success: true, characterId: charId, itemId: r.id, parts: r.parts };
|
|
11783
|
+
}
|
|
11784
|
+
}
|
|
11785
|
+
|
|
11786
|
+
// 2. Try app.create
|
|
11787
|
+
let groupId = null;
|
|
11788
|
+
if (typeof app.create === 'function') {
|
|
11789
|
+
const group = app.create('circle', { x: posX, y: posY, radius: 35 * scale, color: '#7C3AED' });
|
|
11790
|
+
if (group) {
|
|
11791
|
+
group.data = group.data || {};
|
|
11792
|
+
group.data.ppType = 'pp:Character';
|
|
11793
|
+
group.data.characterId = charId;
|
|
11794
|
+
group.name = name;
|
|
11795
|
+
groupId = group.id || (group.data && group.data.id) || charId;
|
|
11796
|
+
}
|
|
11797
|
+
} else {
|
|
11798
|
+
const p = typeof paper !== 'undefined' ? paper : ((typeof window !== 'undefined' ? window : globalThis).paper);
|
|
11799
|
+
if (p && p.Path) {
|
|
11800
|
+
const body = new p.Path.Circle({ center: [posX, posY], radius: 35 * scale, fillColor: '#7C3AED' });
|
|
11801
|
+
const head = new p.Path.Circle({ center: [posX, posY - 45 * scale], radius: 20 * scale, fillColor: '#EC4899' });
|
|
11802
|
+
const group = new p.Group([body, head]);
|
|
11803
|
+
group.name = name;
|
|
11804
|
+
if (app.textItemGroup) group.parent = app.textItemGroup;
|
|
11805
|
+
group.data = { isUserGroup: true, isLayeredCharacter: true, ppType: 'pp:Character', characterId: charId };
|
|
11806
|
+
const reg = app.itemRegistry;
|
|
11807
|
+
groupId = reg ? reg.register(group, 'group', { source: 'mcp_create_character' }, 'mcp') : charId;
|
|
11808
|
+
group.data.id = groupId;
|
|
11809
|
+
}
|
|
11810
|
+
}
|
|
11811
|
+
|
|
11812
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
11813
|
+
if (app.historyManager) app.historyManager.saveState();
|
|
11814
|
+
return { success: true, characterId: charId, itemId: groupId };
|
|
11815
|
+
})();`.trim();
|
|
11816
|
+
}
|
|
11817
|
+
generateCharacterAction(input) {
|
|
11818
|
+
const S = (v) => JSON.stringify(v);
|
|
11819
|
+
const targetId = input.characterId || input.itemId;
|
|
11820
|
+
const act = String(input.action || 'walk').toLowerCase();
|
|
11821
|
+
const speed = input.speed || 100;
|
|
11822
|
+
return `
|
|
11823
|
+
// Character Action
|
|
11824
|
+
(function() {
|
|
11825
|
+
const targetId = ${S(targetId)};
|
|
11826
|
+
const act = ${S(act)};
|
|
11827
|
+
const speed = ${speed};
|
|
11828
|
+
|
|
11829
|
+
const charItem = targetId
|
|
11830
|
+
? (app.getItemById(targetId) || (app.getAllItems && app.getAllItems().find(i => (i.data && i.data.characterId === targetId) || i.name === targetId)))
|
|
11831
|
+
: (app.selectedItems && app.selectedItems[0]);
|
|
11832
|
+
|
|
11833
|
+
if (!charItem) {
|
|
11834
|
+
return { success: false, error: 'Character not found: ' + targetId };
|
|
11835
|
+
}
|
|
11836
|
+
|
|
11837
|
+
const itemId = charItem.id || (charItem.data && charItem.data.id) || targetId;
|
|
11838
|
+
const skelId = (charItem.data && charItem.data.characterId) ? 'character_' + charItem.data.characterId : null;
|
|
11839
|
+
|
|
11840
|
+
if (act === 'walk' || act === 'run' || act === 'locomotion') {
|
|
11841
|
+
if (typeof app.addRelation === 'function') {
|
|
11842
|
+
app.addRelation(itemId, null, 'locomotion', {
|
|
11843
|
+
skeletonId: skelId,
|
|
11844
|
+
speed: speed,
|
|
11845
|
+
cycleDistance: 150,
|
|
11846
|
+
});
|
|
11847
|
+
}
|
|
11848
|
+
} else if (app.riggingSystem) {
|
|
11849
|
+
const rs = app.riggingSystem;
|
|
11850
|
+
if (skelId && rs.skeletons && rs.skeletons.has(skelId)) {
|
|
11851
|
+
if (typeof rs.applyBoneAngles === 'function') {
|
|
11852
|
+
rs.applyBoneAngles(skelId, { spine: 0.1, left_arm: -0.5, right_arm: 0.5 });
|
|
11853
|
+
if (typeof rs.solveFK === 'function') rs.solveFK(skelId);
|
|
11854
|
+
}
|
|
11855
|
+
}
|
|
11856
|
+
}
|
|
11857
|
+
|
|
11858
|
+
if (typeof app.animate === 'function' && (act === 'wave' || act === 'dance' || act === 'jump' || act === 'idle')) {
|
|
11859
|
+
app.animate(charItem, { animationType: act === 'jump' ? 'bounce' : (act === 'idle' ? 'breathe' : 'wobble') });
|
|
11860
|
+
}
|
|
11861
|
+
|
|
11862
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
11863
|
+
return { success: true, characterId: targetId || itemId, action: act };
|
|
11864
|
+
})();`.trim();
|
|
11865
|
+
}
|
|
11866
|
+
generateCharacterPose(input) {
|
|
11867
|
+
const S = (v) => JSON.stringify(v);
|
|
11868
|
+
const targetId = input.characterId || input.itemId;
|
|
11869
|
+
const expr = String(input.expression || 'smile').toLowerCase();
|
|
11870
|
+
const duration = input.duration !== undefined ? input.duration : null;
|
|
11871
|
+
const interval = input.interval !== undefined ? input.interval : null;
|
|
11872
|
+
return `
|
|
11873
|
+
// Character Pose / Expression
|
|
11874
|
+
(function() {
|
|
11875
|
+
const targetId = ${S(targetId)};
|
|
11876
|
+
const expr = ${S(expr)};
|
|
11877
|
+
|
|
11878
|
+
const charItem = targetId
|
|
11879
|
+
? (app.getItemById(targetId) || (app.getAllItems && app.getAllItems().find(i => (i.data && i.data.characterId === targetId) || i.name === targetId)))
|
|
11880
|
+
: (app.selectedItems && app.selectedItems[0]);
|
|
11881
|
+
|
|
11882
|
+
if (!charItem) {
|
|
11883
|
+
return { success: false, error: 'Character not found: ' + targetId };
|
|
11884
|
+
}
|
|
11885
|
+
|
|
11886
|
+
const itemId = charItem.id || (charItem.data && charItem.data.id) || targetId;
|
|
11887
|
+
|
|
11888
|
+
// Touch riggingSystem first to ensure expression rules are initialized
|
|
11889
|
+
const rs = app.riggingSystem;
|
|
11890
|
+
if (typeof app.addRelation === 'function') {
|
|
11891
|
+
app.addRelation(itemId, null, 'expresses', {
|
|
11892
|
+
expression: expr,
|
|
11893
|
+
duration: ${duration === null ? 'null' : duration},
|
|
11894
|
+
interval: ${interval === null ? 'null' : interval},
|
|
11895
|
+
});
|
|
11896
|
+
}
|
|
11897
|
+
|
|
11898
|
+
if (app._scheduleRepaint) app._scheduleRepaint();
|
|
11899
|
+
return { success: true, characterId: targetId || itemId, expression: expr };
|
|
11900
|
+
})();`.trim();
|
|
11901
|
+
}
|
|
11902
|
+
/**
|
|
11903
|
+
* pinepaper_look (D64): one look over an item, a subtree or the whole scene
|
|
11904
|
+
* — ids, keys and relations kept, removable pixel for pixel. Every apply
|
|
11905
|
+
* carries the look's own FIDELITY grade, and anything below 'convincing'
|
|
11906
|
+
* says so in the reply, so a weak approximation is never handed over as
|
|
11907
|
+
* the finished style.
|
|
11908
|
+
*/
|
|
11909
|
+
generateLook(input) {
|
|
11910
|
+
const S = (v) => JSON.stringify(v);
|
|
11911
|
+
const guard = `if (typeof app.applyLook !== 'function') { return { success: false, error: 'this studio has no looks (app.applyLook) — it predates FxTool D64.' }; }`;
|
|
11912
|
+
const target = S(input.target ?? 'scene');
|
|
11913
|
+
switch (input.action) {
|
|
11914
|
+
case 'list':
|
|
11915
|
+
return `(function() {\n ${guard}\n return { success: true, action: 'list', looks: app.listLooks(), note: 'fidelity is the engine grade: native (the medium itself), faithful (close, approximations named), impression (an evocation, not the real style). Describe one before promising it.' };\n})();`;
|
|
11916
|
+
case 'describe':
|
|
11917
|
+
return `(function() {\n ${guard}\n const r = app.describeLook(${S(input.look)});\n if (!r || r.ok === false) return { success: false, action: 'describe', error: (r && r.error) || 'unknown look', known: r && r.known };\n return { success: true, action: 'describe', fidelity: r.look && r.look.fidelity, approximations: (r.look && r.look.approximations) || [], recipe: r.look, enginePaths: r.enginePaths, sceneOnly: r.sceneOnly };\n})();`;
|
|
11918
|
+
case 'get':
|
|
11919
|
+
return `(function() {\n ${guard}\n return { success: true, action: 'get', target: ${target}, look: app.getLook(${target}) || null };\n})();`;
|
|
11920
|
+
case 'remove':
|
|
11921
|
+
return `
|
|
11922
|
+
// Look: remove (restores what was there)
|
|
11923
|
+
(async function() {
|
|
11924
|
+
${guard}
|
|
11925
|
+
const r = await app.removeLook(${target});
|
|
11926
|
+
if (!r || r.ok === false) return { success: false, action: 'remove', error: (r && r.error) || 'nothing to remove' };
|
|
11927
|
+
return { success: true, action: 'remove', target: ${target}, removed: r.removed, scene: r.scene };
|
|
11928
|
+
})();`.trim();
|
|
11929
|
+
case 'grid':
|
|
11930
|
+
return `
|
|
11931
|
+
// Look: a contact sheet of looks over the current scene
|
|
11932
|
+
(async function() {
|
|
11933
|
+
${guard}
|
|
11934
|
+
const r = await app.renderLookGrid(${input.looks ? S(input.looks) : 'undefined'}, ${S({ ...(input.cols ? { cols: input.cols } : {}), ...(input.cellWidth ? { cellWidth: input.cellWidth } : {}), ...(input.t !== undefined ? { t: input.t } : {}) })});
|
|
11935
|
+
if (!r || r.ok === false || !r.dataURL) return { success: false, action: 'grid', error: (r && r.error) || 'the grid did not render' };
|
|
11936
|
+
return { success: true, action: 'grid', data: r.dataURL, mimeType: 'image/png', cells: (r.cells || []).map(function (c) { return { id: c.id, name: c.name, fidelity: c.fidelity }; }), cols: r.cols, rows: r.rows };
|
|
11937
|
+
})();`.trim();
|
|
11938
|
+
default: {
|
|
11939
|
+
const opts = input.scope ? { scope: input.scope } : {};
|
|
11940
|
+
return `
|
|
11941
|
+
// Look: apply
|
|
11942
|
+
(async function() {
|
|
11943
|
+
${guard}
|
|
11944
|
+
const d = app.describeLook(${S(input.look)});
|
|
11945
|
+
const r = await app.applyLook(${target}, ${S(input.look)}, ${S(opts)});
|
|
11946
|
+
if (!r || r.ok === false) return { success: false, action: 'apply', error: (r && r.error) || 'the look was refused', known: r && r.known };
|
|
11947
|
+
const fidelity = (d && d.look && d.look.fidelity) || (r.look && r.look.fidelity) || null;
|
|
11948
|
+
const res = { success: true, action: 'apply', target: ${target}, look: r.look && (r.look.id || r.look.name), fidelity: fidelity,
|
|
11949
|
+
restyled: r.restyled, texts: r.texts, images: r.images, brushed: r.brushed, keyframes: r.keyframes, filters: r.filters, ids: r.ids,
|
|
11950
|
+
approximations: r.approximations || [], warnings: r.warnings || [] };
|
|
11951
|
+
// The engine's grades: native (the medium itself), faithful (close, with
|
|
11952
|
+
// named approximations), impression (an evocation), custom (an ad-hoc spec).
|
|
11953
|
+
const __approx = (res.approximations && res.approximations.length) ? res.approximations.join('; ') : '';
|
|
11954
|
+
if (fidelity === 'impression') {
|
|
11955
|
+
res.warning = 'this look is an IMPRESSION of the style (the engine\\'s grade)' + (__approx ? ' — it approximates: ' + __approx : '') + '. Present it as an approximation of the style, not the finished style.';
|
|
11956
|
+
} else if (fidelity === 'faithful' && __approx) {
|
|
11957
|
+
res.note = 'a faithful look; what it approximates: ' + __approx + '.';
|
|
11958
|
+
} else if (fidelity === 'custom') {
|
|
11959
|
+
res.note = 'a custom look spec: the engine has no fidelity grade for it.';
|
|
11960
|
+
}
|
|
11961
|
+
return res;
|
|
11962
|
+
})();`.trim();
|
|
11963
|
+
}
|
|
11964
|
+
}
|
|
11965
|
+
}
|
|
11966
|
+
/** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
|
|
11967
|
+
generateEmitter(input) {
|
|
11968
|
+
const S = (v) => JSON.stringify(v);
|
|
11969
|
+
if (input.action === 'set') {
|
|
11970
|
+
return `
|
|
11971
|
+
// Emitter: change a spec
|
|
11972
|
+
(function() {
|
|
11973
|
+
if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
|
|
11974
|
+
const r = app.setEmitter(${S(input.itemId)}, ${S(input.spec)});
|
|
11975
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the emitter change was refused' }; }
|
|
11976
|
+
return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec };
|
|
11977
|
+
})();`.trim();
|
|
11978
|
+
}
|
|
11979
|
+
return `
|
|
11980
|
+
// Emitter: create
|
|
11981
|
+
(function() {
|
|
11982
|
+
if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
|
|
11983
|
+
const it = app.create('emitter', ${S(input.spec)});
|
|
11984
|
+
// A bad spec comes back null with an emitter-invalid warning; the reason
|
|
11985
|
+
// reaches this reply through the governor's warnings.
|
|
11986
|
+
if (!it) { return { success: false, error: 'the emitter spec was refused' }; }
|
|
11987
|
+
const id = it.data && (it.data.registryId || it.data.id);
|
|
11988
|
+
if (!id) { return { success: false, error: 'the emitter was made but not registered — no usable id' }; }
|
|
11989
|
+
return { success: true, action: 'create', itemId: id };
|
|
11990
|
+
})();`.trim();
|
|
11991
|
+
}
|
|
11992
|
+
/** pinepaper_render_hook (D29): drawing saved as source, re-installed on load. */
|
|
11993
|
+
generateRenderHook(input) {
|
|
11994
|
+
const S = (v) => JSON.stringify(v);
|
|
11995
|
+
const guard = `if (typeof app.registerRenderHook !== 'function') { return { success: false, error: 'this studio has no saved render hooks (app.registerRenderHook) — it predates FxTool D29.' }; }`;
|
|
11996
|
+
if (input.action === 'list') {
|
|
11997
|
+
return `(function() {\n ${guard}\n return { success: true, action: 'list', hooks: app.listRenderHooks() };\n})();`;
|
|
11998
|
+
}
|
|
11999
|
+
if (input.action === 'unregister') {
|
|
12000
|
+
return `(function() {\n ${guard}\n const had = app.unregisterRenderHook(${S(input.id)});\n if (!had) return { success: false, error: 'no render hook ' + ${S(input.id)} };\n return { success: true, action: 'unregister', id: ${S(input.id)} };\n})();`;
|
|
12001
|
+
}
|
|
12002
|
+
const spec = { id: input.id, source: input.source, ...(input.layer ? { layer: input.layer } : {}), ...(input.deterministic !== undefined ? { deterministic: input.deterministic } : {}), ...(input.seed !== undefined ? { seed: input.seed } : {}) };
|
|
12003
|
+
return `
|
|
12004
|
+
// Render hook: register (saved as source)
|
|
12005
|
+
(function() {
|
|
12006
|
+
${guard}
|
|
12007
|
+
const r = app.registerRenderHook(${S(spec)});
|
|
12008
|
+
if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the render hook was refused' }; }
|
|
12009
|
+
return { success: true, action: 'register', id: r.id, layer: r.layer, deterministic: r.deterministic, seed: r.seed };
|
|
12010
|
+
})();`.trim();
|
|
12011
|
+
}
|
|
11009
12012
|
generateRelight(input) {
|
|
11010
12013
|
const S = (v) => JSON.stringify(v);
|
|
11011
12014
|
const missing = (fn) => `if (typeof app.${fn} !== 'function') { return { success: false, action: ${S(input.action)}, error: 'this studio has no relight pass (app.${fn}) — it predates FxTool D5.' }; }`;
|
|
@@ -11151,7 +12154,7 @@ ${body}
|
|
|
11151
12154
|
}
|
|
11152
12155
|
return { success: true, action: 'apply', medium: ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id || ${S(input.itemId)}, inPlace: true, styled: __styled, ...(r.note ? { note: r.note } : {}) };
|
|
11153
12156
|
}
|
|
11154
|
-
return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.note ? { note: r.note } : {}) };
|
|
12157
|
+
return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.filter ? { filter: r.filter } : {}), ...(r.note ? { note: r.note } : {}) };
|
|
11155
12158
|
})();`.trim();
|
|
11156
12159
|
case 'sdf_stroke': {
|
|
11157
12160
|
// FxTool D5: an SDF capsule stroke rendered to a bitmap and created as a
|
|
@@ -12080,7 +13083,14 @@ ${needWorld}
|
|
|
12080
13083
|
*/
|
|
12081
13084
|
generateStory(input) {
|
|
12082
13085
|
const S = (v) => JSON.stringify(v);
|
|
12083
|
-
|
|
13086
|
+
// nav decides the story's FORM (D109): 'scenes' = a scene chain (video),
|
|
13087
|
+
// 'panels' = tabs (widget). storyFromText reads options.nav; a spec carries
|
|
13088
|
+
// its own nav, which a top-level nav fills in when the spec has none.
|
|
13089
|
+
const opts = S({ ...(input.options ?? {}), ...(input.nav ? { nav: input.nav } : {}) });
|
|
13090
|
+
const spec = input.spec && input.nav && input.spec.nav === undefined ? { ...input.spec, nav: input.nav } : input.spec;
|
|
13091
|
+
const cuts = input.nav === 'scenes'
|
|
13092
|
+
? `\n // The video form's cut times (s), for checking the edit.\n try { if (app.sceneManager && app.sceneManager.getChainCuts) __out.cuts = app.sceneManager.getChainCuts(); } catch (_) {}`
|
|
13093
|
+
: '';
|
|
12084
13094
|
switch (input.action) {
|
|
12085
13095
|
case 'distill':
|
|
12086
13096
|
return `
|
|
@@ -12106,16 +13116,18 @@ ${needWorld}
|
|
|
12106
13116
|
// fine in-page. Not a cycle; JSON.stringify succeeded on this the whole time.
|
|
12107
13117
|
// The groups have to stay for in-page callers, so the wrapper takes what
|
|
12108
13118
|
// travels: the spec, the ids, the count.
|
|
12109
|
-
|
|
13119
|
+
const __out = { success: true, nav: (r.spec && r.spec.nav) || ${S(input.nav ?? 'panels')}, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };${cuts}
|
|
13120
|
+
return __out;
|
|
12110
13121
|
})();`.trim();
|
|
12111
13122
|
case 'apply_spec':
|
|
12112
13123
|
return `
|
|
12113
13124
|
// Story: assemble a spec you already have
|
|
12114
13125
|
(async function() {
|
|
12115
13126
|
if (typeof app.applyStorySpec !== 'function') { return { success: false, error: 'app.applyStorySpec unavailable — update FxTool' }; }
|
|
12116
|
-
const r = await app.applyStorySpec(${S(
|
|
13127
|
+
const r = await app.applyStorySpec(${S(spec)}, ${opts});
|
|
12117
13128
|
if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the spec did not assemble' }; }
|
|
12118
|
-
|
|
13129
|
+
const __out = { success: true, ...r };${cuts}
|
|
13130
|
+
return __out;
|
|
12119
13131
|
})();`.trim();
|
|
12120
13132
|
case 'plan_book':
|
|
12121
13133
|
return `
|
|
@@ -13134,7 +14146,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
|
|
|
13134
14146
|
generateEvent(input) {
|
|
13135
14147
|
if (input.action === 'create') {
|
|
13136
14148
|
const nameJson = JSON.stringify(input.name);
|
|
13137
|
-
const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y });
|
|
14149
|
+
const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y, ...(input.at !== undefined ? { at: input.at } : {}) });
|
|
13138
14150
|
return `
|
|
13139
14151
|
// Create event: ${input.name}
|
|
13140
14152
|
(function() {
|
|
@@ -13144,7 +14156,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
|
|
|
13144
14156
|
const eventId = app.createEvent(${nameJson}, ${optsJson});
|
|
13145
14157
|
if (!eventId) { return { success: false, error: 'Event creation failed' }; }
|
|
13146
14158
|
if (app.historyManager) app.historyManager.saveState();
|
|
13147
|
-
return { success: true, action: 'create', eventId };
|
|
14159
|
+
return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
|
|
13148
14160
|
})();`.trim();
|
|
13149
14161
|
}
|
|
13150
14162
|
// pulse — a runtime fire, not a scene edit (no history save)
|