@pinepaper.studio/mcp-server 1.6.30 → 1.6.32
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 +131 -2
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SECURITY.md +15 -0
- package/dist/agent/types.d.ts +10 -10
- package/dist/browser/puppeteer-controller.d.ts.map +1 -1
- package/dist/browser/puppeteer-controller.js +83 -43
- package/dist/browser/puppeteer-controller.js.map +1 -1
- package/dist/build-info.json +2 -2
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/i18n/i18n-manager.d.ts.map +1 -1
- package/dist/i18n/i18n-manager.js +57 -3
- package/dist/i18n/i18n-manager.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +12 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +32 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/stdio.d.ts +4 -0
- package/dist/mcp/stdio.d.ts.map +1 -1
- package/dist/mcp/stdio.js +22 -0
- package/dist/mcp/stdio.js.map +1 -1
- package/dist/ontology/upstream-vocabulary.d.ts +2 -2
- package/dist/ontology/upstream-vocabulary.js +2 -2
- package/dist/ontology/vocabulary.d.ts.map +1 -1
- package/dist/ontology/vocabulary.js +18 -0
- package/dist/ontology/vocabulary.js.map +1 -1
- package/dist/tools/batch-op-names.d.ts +33 -0
- package/dist/tools/batch-op-names.d.ts.map +1 -0
- package/dist/tools/batch-op-names.js +140 -0
- package/dist/tools/batch-op-names.js.map +1 -0
- package/dist/tools/choreograph.d.ts +8 -0
- package/dist/tools/choreograph.d.ts.map +1 -1
- package/dist/tools/choreograph.js +9 -7
- package/dist/tools/choreograph.js.map +1 -1
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +109 -45
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/effect-class.d.ts.map +1 -1
- package/dist/tools/effect-class.js +7 -0
- package/dist/tools/effect-class.js.map +1 -1
- package/dist/tools/engine-lists.d.ts +7 -4
- package/dist/tools/engine-lists.d.ts.map +1 -1
- package/dist/tools/engine-lists.js +17 -4
- 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 +3 -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 +21 -4
- package/dist/tools/engine-surface.js.map +1 -1
- package/dist/tools/handlers/generate.d.ts +2 -2
- package/dist/tools/handlers.d.ts +7 -0
- package/dist/tools/handlers.d.ts.map +1 -1
- package/dist/tools/handlers.js +63 -4
- package/dist/tools/handlers.js.map +1 -1
- package/dist/tools/lane.d.ts +1 -1
- package/dist/tools/lane.d.ts.map +1 -1
- package/dist/tools/lane.js +1 -1
- package/dist/tools/lane.js.map +1 -1
- package/dist/tools/minimal-descriptions.js +1 -1
- package/dist/tools/minimal-descriptions.js.map +1 -1
- package/dist/tools/shape-params.d.ts +2 -2
- package/dist/tools/shape-params.d.ts.map +1 -1
- package/dist/tools/shape-params.js +8 -2
- package/dist/tools/shape-params.js.map +1 -1
- package/dist/tools/stick-vocabulary.d.ts +4 -4
- package/dist/tools/stick-vocabulary.d.ts.map +1 -1
- package/dist/tools/stick-vocabulary.js +15 -4
- package/dist/tools/stick-vocabulary.js.map +1 -1
- package/dist/types/code-generator.d.ts +25 -1
- package/dist/types/code-generator.d.ts.map +1 -1
- package/dist/types/code-generator.js +362 -105
- package/dist/types/code-generator.js.map +1 -1
- package/dist/types/schemas.d.ts +246 -105
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +70 -6
- package/dist/types/schemas.js.map +1 -1
- package/dist/utils/capabilities.d.ts.map +1 -1
- package/dist/utils/capabilities.js +3 -1
- package/dist/utils/capabilities.js.map +1 -1
- package/dist/utils/env.d.ts +19 -6
- package/dist/utils/env.d.ts.map +1 -1
- package/dist/utils/env.js +34 -6
- package/dist/utils/env.js.map +1 -1
- package/dist/utils/exit-when-client-gone.d.ts +10 -0
- package/dist/utils/exit-when-client-gone.d.ts.map +1 -0
- package/dist/utils/exit-when-client-gone.js +21 -0
- package/dist/utils/exit-when-client-gone.js.map +1 -0
- package/dist/utils/safe-fetch.d.ts +1 -0
- package/dist/utils/safe-fetch.d.ts.map +1 -1
- package/dist/utils/safe-fetch.js +3 -4
- package/dist/utils/safe-fetch.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 +5 -2
- package/dist/vendor/design/design-style.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { compileChoreography } from '../tools/choreograph.js';
|
|
8
8
|
import { compileBatchTimelines, emitTimelineOpBody } from '../tools/timeline.js';
|
|
9
9
|
import { normalizeSvgSize } from '../utils/svg-size.js';
|
|
10
|
-
import { WORLD3D_COLOR_PATHS, PATTERN_KINDS, EXPRESSION_FUNCTION_ARITY, EXPRESSION_CONSTANTS, EXPRESSION_REJECTED, RELATION_PARAM_KEYS } from '../tools/engine-lists.js';
|
|
10
|
+
import { WORLD3D_COLOR_PATHS, PATTERN_KINDS, EXPRESSION_FUNCTION_ARITY, EXPRESSION_CONSTANTS, EXPRESSION_REJECTED, RELATION_PARAM_KEYS, ANIMATION_OPTION_KEYS } from '../tools/engine-lists.js';
|
|
11
11
|
import { expressionGrammarText } from '../tools/expression-grammar.js';
|
|
12
12
|
import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
|
|
13
13
|
import { NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
|
|
@@ -16,6 +16,16 @@ import { jsLiteral } from '../utils/js-literal.js';
|
|
|
16
16
|
/** A value spliced into a generated `//` comment, on one line: a line break in caller text would end the comment and run the rest as code. */
|
|
17
17
|
/** How many relations pinepaper_lint_scene lists back; past it the result says how many there are. */
|
|
18
18
|
const RELATIONS_READBACK_CAP = 200;
|
|
19
|
+
/**
|
|
20
|
+
* A relation's parameters as a caller may read them: the engine's own rule for what leaves a relation
|
|
21
|
+
* (RelationRegistry.stripTransientParams, its save boundary), applied in the page because that is where the value is.
|
|
22
|
+
*
|
|
23
|
+
* The engine keeps its working state in the stored params: _itemRegistry (written on the first frame), _initialized,
|
|
24
|
+
* _startTime, _affectedItems, listeners as functions. Read back raw, a reply carried the registry, so it failed the page
|
|
25
|
+
* bridge's structured clone and took the walker's path, where the whole reply was truncated to fit. Sent back in an update
|
|
26
|
+
* op, the same keys overwrite live state: the engine fills those slots only when empty, so a stale copy stays for good.
|
|
27
|
+
*/
|
|
28
|
+
const RELATION_PUBLIC_PARAMS_JS = "((p) => Object.fromEntries(Object.entries(p || {}).filter(([k, v]) => k.charAt(0) !== '_' && typeof v !== 'function')))";
|
|
19
29
|
function oneLine(v) {
|
|
20
30
|
return String(v).replace(/[\r\n\u2028\u2029]+/g, ' ');
|
|
21
31
|
}
|
|
@@ -182,12 +192,13 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
|
|
|
182
192
|
if (properties.content === undefined && properties.text !== undefined) {
|
|
183
193
|
properties = { ...properties, content: properties.text };
|
|
184
194
|
}
|
|
195
|
+
// The alias replaces the key: left beside justification, the engine's receipt
|
|
196
|
+
// reported textAlign 'justify' as refused while it had been applied.
|
|
185
197
|
if (properties.justification === undefined) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
properties = { ...properties, justification: properties.align };
|
|
198
|
+
const alignKey = properties.textAlign !== undefined ? 'textAlign' : properties.align !== undefined ? 'align' : null;
|
|
199
|
+
if (alignKey) {
|
|
200
|
+
const { [alignKey]: alignValue, ...rest } = properties;
|
|
201
|
+
properties = { ...rest, justification: cssAlignToJustification(alignValue) };
|
|
191
202
|
}
|
|
192
203
|
}
|
|
193
204
|
// Extract special properties that need separate handling
|
|
@@ -218,6 +229,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
|
|
|
218
229
|
textDirection.resolved = baseDirection(createProps.content);
|
|
219
230
|
}
|
|
220
231
|
const textTabular = itemType === 'text' && wantsTabular(createProps) === true;
|
|
232
|
+
const textTypography = itemType === 'text' ? typographyRequest(createProps) : [];
|
|
221
233
|
const createFillOpacity = createProps.fillOpacity !== undefined;
|
|
222
234
|
const textStrokePos = itemType === 'text' ? takeStrokePosition(createProps, false) : null;
|
|
223
235
|
const params = {
|
|
@@ -422,6 +434,8 @@ const __scale = (function(it) {
|
|
|
422
434
|
code += emitDirectionCheck('item', textDirection);
|
|
423
435
|
if (textTabular)
|
|
424
436
|
code += emitTabularCheck('item');
|
|
437
|
+
if (textTypography.length)
|
|
438
|
+
code += emitTypographyCheck('item', textTypography);
|
|
425
439
|
if (createFillOpacity)
|
|
426
440
|
code += emitFillOpacityCheck('item');
|
|
427
441
|
if (textStrokePos)
|
|
@@ -476,7 +490,7 @@ String(outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : '') +
|
|
|
476
490
|
String(textFit ? ', textFit: __textFit' : '') +
|
|
477
491
|
String(textDirection ? ', direction: __direction' : '') +
|
|
478
492
|
String(textTabular ? ', tabularFigures: __tabular' : '') +
|
|
479
|
-
String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
|
|
493
|
+
String(textTypography.length ? ', typography: __typography' : '') + String(createFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(textStrokePos ? ', strokePosition: __strokePos' : '') + String(hasCreateScale ? ', scale: __scale' : '') + ", position: " + String(reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`) + String(hasLifetime ? ', lifetime: __lifetime' : '') + String(loadsFont ? ', ...(__font ? { font: __font } : {})' : '') + String(ignored.length > 0
|
|
480
494
|
? `, ignoredProperties: ${JSON.stringify(ignored)}` : '') + String(warnings.length > 0 ? `, warning: ${JSON.stringify(warnings.join(' '))}` : '') + " });");
|
|
481
495
|
// Loading a font is async, and it has to happen BEFORE create() measures the
|
|
482
496
|
// text — so only this case becomes an async IIFE; every other create stays a
|
|
@@ -743,6 +757,50 @@ function anchorHold(anchor) {
|
|
|
743
757
|
const a = String(anchor ?? '').toLowerCase();
|
|
744
758
|
return /^top/.test(a) ? 'top' : /^bottom/.test(a) ? 'bottom' : undefined;
|
|
745
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* wordSpacing, textTransform, textDecoration and justify (FxTool #289, WORDSPC).
|
|
762
|
+
* The engine stores them on item.data and its text layer draws them; a studio
|
|
763
|
+
* before #289 has no such layer and the keys are lost. These keys are on the
|
|
764
|
+
* current engine's read list (shape-params.ts), so the unread-key report no
|
|
765
|
+
* longer names them; the reply instead reads item.data, so an older studio is
|
|
766
|
+
* still named rather than reported as done.
|
|
767
|
+
*/
|
|
768
|
+
/**
|
|
769
|
+
* textAlign / align, the CSS names, as Paper's justification: start / end read
|
|
770
|
+
* left / right and centre reads center, as the engine's own textAlign door does.
|
|
771
|
+
* Anything else passes through for the engine to judge (and report).
|
|
772
|
+
*/
|
|
773
|
+
function cssAlignToJustification(v) {
|
|
774
|
+
if (typeof v !== 'string')
|
|
775
|
+
return v;
|
|
776
|
+
const k = v.trim().toLowerCase();
|
|
777
|
+
return { start: 'left', end: 'right', centre: 'center', left: 'left', center: 'center', right: 'right', justify: 'justify' }[k] ?? v;
|
|
778
|
+
}
|
|
779
|
+
const TYPOGRAPHY_KEYS = ['wordSpacing', 'textTransform', 'textDecoration'];
|
|
780
|
+
function typographyRequest(props) {
|
|
781
|
+
const keys = TYPOGRAPHY_KEYS.filter((k) => props[k] !== undefined);
|
|
782
|
+
const align = props.justification ?? props.textAlign ?? props.align;
|
|
783
|
+
if (typeof align === 'string' && align.trim().toLowerCase() === 'justify')
|
|
784
|
+
keys.push('justify');
|
|
785
|
+
return keys;
|
|
786
|
+
}
|
|
787
|
+
function emitTypographyCheck(itemExpr, keys) {
|
|
788
|
+
return ("\nconst __typography = (function(it, keys) {\n if (!it || it.className !== 'PointText') return { applied: false, note: keys.join(', ') + ' apply to text items only.' };\n const d = it.data || {};\n if (typeof app._applyTextTypography !== 'function') return { applied: false, ignored: keys, note: 'this studio does not draw ' + keys.join(', ') + ' (it needs an engine with text typography): the text is drawn without ' + (keys.length > 1 ? 'them' : 'it') + '. Set the case in the content itself; for an underline, a line item under the text.' };\n const out = { applied: true };\n const unset = [];\n for (const k of keys) {\n const v = k === 'justify' ? (d.justify === true ? true : undefi" +
|
|
789
|
+
"ned) : d[k];\n out[k] = v === undefined ? null : v;\n if (v === undefined) unset.push(k);\n }\n if (unset.length) out.note = unset.join(', ') + (unset.length > 1 ? ' are' : ' is') + ' not set on the item now: a clearing value (none / normal / 0) removes it, and a value the studio cannot draw is refused by name in the engine receipt.';\n return out;\n})(" +
|
|
790
|
+
String(itemExpr) +
|
|
791
|
+
", " +
|
|
792
|
+
String(JSON.stringify(keys)) +
|
|
793
|
+
");"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
);
|
|
803
|
+
}
|
|
746
804
|
function emitTextFit(itemExpr, box, holdFallback = 'undefined') {
|
|
747
805
|
return `
|
|
748
806
|
if (typeof app.fitText === 'function') {
|
|
@@ -978,12 +1036,12 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
|
|
|
978
1036
|
const aliased = new Set();
|
|
979
1037
|
if (restProperties.justification === undefined) {
|
|
980
1038
|
if (restProperties.textAlign !== undefined) {
|
|
981
|
-
restProperties.justification = restProperties.textAlign;
|
|
1039
|
+
restProperties.justification = cssAlignToJustification(restProperties.textAlign);
|
|
982
1040
|
delete restProperties.textAlign;
|
|
983
1041
|
aliased.add('textAlign');
|
|
984
1042
|
}
|
|
985
1043
|
else if (restProperties.align !== undefined) {
|
|
986
|
-
restProperties.justification = restProperties.align;
|
|
1044
|
+
restProperties.justification = cssAlignToJustification(restProperties.align);
|
|
987
1045
|
delete restProperties.align;
|
|
988
1046
|
aliased.add('align');
|
|
989
1047
|
}
|
|
@@ -1001,6 +1059,7 @@ function generateModifyItemCode(itemId, properties, dataFlags) {
|
|
|
1001
1059
|
delete restProperties.fit;
|
|
1002
1060
|
const modDirection = takeDirection(restProperties);
|
|
1003
1061
|
const modTabular = wantsTabular(restProperties) === true;
|
|
1062
|
+
const modTypography = typographyRequest(restProperties);
|
|
1004
1063
|
const modFillOpacity = restProperties.fillOpacity !== undefined;
|
|
1005
1064
|
const modStrokePos = takeStrokePosition(restProperties, true);
|
|
1006
1065
|
// modifyItem(id, changes), NOT select() + modify().
|
|
@@ -1145,12 +1204,12 @@ const __ctKeys = (__ctItem && __ctItem.data && Array.isArray(__ctItem.data.keyfr
|
|
|
1145
1204
|
"\n" +
|
|
1146
1205
|
String(modDirection ? emitDirectionCheck(`app.getItemById(${ID})`, modDirection) : '') +
|
|
1147
1206
|
String(modTabular ? emitTabularCheck(`app.getItemById(${ID})`) : '') +
|
|
1207
|
+
String(modTypography.length ? emitTypographyCheck(`app.getItemById(${ID})`, modTypography) : '') +
|
|
1148
1208
|
String(modFillOpacity ? emitFillOpacityCheck(`app.getItemById(${ID})`) : '') +
|
|
1149
1209
|
String(modStrokePos ? emitStrokePositionCheck(`app.getItemById(${ID})`, modStrokePos) : '') +
|
|
1150
1210
|
"\nreturn { success: true, itemId: " +
|
|
1151
1211
|
String(ID) +
|
|
1152
|
-
String(properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : '') +
|
|
1153
|
-
String(modFit ? ', textFit: __textFit' : '') + String(modDirection ? ', direction: __direction' : '') + String(modTabular ? ', tabularFigures: __tabular' : '') + String(modFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(modStrokePos ? ', strokePosition: __strokePos' : '') + String(affine ? `, affine: ${jsLiteral({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : '') + String(modLifetime ? ', lifetime: __lifetime' : '') + String(modFont ? ', ...(__font ? { font: __font } : {})' : '') + String(modWarning) + " };");
|
|
1212
|
+
String(properties.content !== undefined ? `, ...(__ctKeys ? { contentTrackWarning: 'this text\\'s content is keyframed (' + __ctKeys + ' key(s)), so the track sets it on every frame and this edit does not show. Change the content keys with pinepaper_keyframe_animate (append: true replaces same-time keys), or animate another property.' } : {})` : '') + String(modFit ? ', textFit: __textFit' : '') + String(modDirection ? ', direction: __direction' : '') + String(modTabular ? ', tabularFigures: __tabular' : '') + String(modTypography.length ? ', typography: __typography' : '') + String(modFillOpacity ? ', fillOpacity: __fillOpacity' : '') + String(modStrokePos ? ', strokePosition: __strokePos' : '') + String(affine ? `, affine: ${jsLiteral({ skewX, skewY, matrix, note: 'applied to the current shape: calling again compounds it. To undo, apply the inverse (negative skew, or the inverse matrix).' })}` : '') + String(modLifetime ? ', lifetime: __lifetime' : '') + String(modFont ? ', ...(__font ? { font: __font } : {})' : '') + String(modWarning) + " };");
|
|
1154
1213
|
// Wrapped, because the body now RETURNS early on a miss rather than falling
|
|
1155
1214
|
// through to an unconditional success. Async only when a font must load
|
|
1156
1215
|
// before modifyItem re-measures the text.
|
|
@@ -1258,14 +1317,33 @@ export function fireIfProblem(params) {
|
|
|
1258
1317
|
}
|
|
1259
1318
|
return null;
|
|
1260
1319
|
}
|
|
1320
|
+
/**
|
|
1321
|
+
* A TARGET-LESS on_event_step_active runs only on a studio that says so. An older studio runs an event reaction only
|
|
1322
|
+
* when its target exists (a sound alone was exempt), and step_active acts on its GROUP, not its target, so one bound
|
|
1323
|
+
* with no target reported success and never stepped (beta STEPACT30). FxTool #281 runs it target-less and lists it in
|
|
1324
|
+
* PinePaper.TARGETLESS_EVENT_RELATIONS, the set _bindOnEventRelation itself reads, so the marker cannot say yes while
|
|
1325
|
+
* the engine says no. The refusal is decided in the page (STEP_ACTIVE_TARGETLESS_JS); here, only whether to ask.
|
|
1326
|
+
*/
|
|
1327
|
+
export function targetlessStepProblem(relationType, target) {
|
|
1328
|
+
if (relationType !== 'on_event_step_active' || (target != null && target !== ''))
|
|
1329
|
+
return null;
|
|
1330
|
+
return 'on_event_step_active needs a targetId on this studio. It steps the group named in params.groupName, not its target, but this studio runs an event reaction only when its target exists: without one it binds, reports success and never runs. Point it at any item, for example one member of the group (a current studio runs it without one).';
|
|
1331
|
+
}
|
|
1332
|
+
/** True in the page when the studio runs on_event_step_active with no target. */
|
|
1333
|
+
export const STEP_ACTIVE_TARGETLESS_JS = "(typeof app !== 'undefined' && !!app && app.TARGETLESS_EVENT_RELATIONS?.has?.('on_event_step_active') === true)";
|
|
1261
1334
|
/**
|
|
1262
1335
|
* fireIfProblem for any relation a tool is about to add: an on_event_fire_if
|
|
1263
1336
|
* itself, or one an on_event_add_relation will add when its event fires
|
|
1264
1337
|
* (params.type / params.params). Every path that adds relations asks this, so
|
|
1265
1338
|
* the check does not depend on which tool built the relation.
|
|
1266
1339
|
*/
|
|
1267
|
-
export function relationSpecProblem(relationType, params) {
|
|
1340
|
+
export function relationSpecProblem(relationType, params, ...target) {
|
|
1268
1341
|
const p = (params && typeof params === 'object' ? params : {});
|
|
1342
|
+
// A target-less on_event_step_active is decided in the page, not here (targetlessStepProblem): it runs on a studio
|
|
1343
|
+
// that lists it in TARGETLESS_EVENT_RELATIONS and does nothing on an older one. Only a caller with no page to ask
|
|
1344
|
+
// (create_scene, which binds no relation without a target) refuses it outright.
|
|
1345
|
+
if (target.length > 1 && target[1] === 'no-page' && targetlessStepProblem(relationType, target[0]))
|
|
1346
|
+
return targetlessStepProblem(relationType, target[0]);
|
|
1269
1347
|
if (relationType === 'on_event_fire_if')
|
|
1270
1348
|
return fireIfProblem(p);
|
|
1271
1349
|
if (relationType === 'time_expression' && typeof p.expression === 'string')
|
|
@@ -1372,6 +1450,45 @@ function editDistance(a, b) {
|
|
|
1372
1450
|
}
|
|
1373
1451
|
return row[b.length];
|
|
1374
1452
|
}
|
|
1453
|
+
/**
|
|
1454
|
+
* The params keys a custom relation reads, worked out when it is registered: what it declares, every `params.X` its
|
|
1455
|
+
* compute / apply source reads, and the index / count a name pattern adds. null when the source uses params any other
|
|
1456
|
+
* way (params[k], destructuring, handing params on), because then the list could not be trusted and a warning could
|
|
1457
|
+
* name a key that works; null means no check, never a wrong one (RELWARN27: a bogus option on a built-in relation was
|
|
1458
|
+
* named, the same option on a custom one was stored in silence).
|
|
1459
|
+
*/
|
|
1460
|
+
export function customRelationReads(params, computeFunction, applyFunction) {
|
|
1461
|
+
const src = `${String(computeFunction ?? '')}\n${String(applyFunction ?? '')}`.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
|
|
1462
|
+
const bare = [...src.matchAll(/\bparams\b(?!\s*\??\.\s*[A-Za-z_$])/g)];
|
|
1463
|
+
if (bare.length)
|
|
1464
|
+
return null;
|
|
1465
|
+
const keys = new Set(['index', 'count']);
|
|
1466
|
+
if (params && typeof params === 'object')
|
|
1467
|
+
Object.keys(params).forEach((k) => keys.add(k));
|
|
1468
|
+
for (const m of src.matchAll(/\bparams\s*\??\.\s*([A-Za-z_$][\w$]*)/g))
|
|
1469
|
+
keys.add(m[1]);
|
|
1470
|
+
return [...keys].sort();
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* A relation's options checked against a CUSTOM relation's read list, in the page (the rule was registered there, maybe
|
|
1474
|
+
* in an earlier call). A fragment for a result object: `, ...{ key: warning }` or nothing. Built-ins are checked here
|
|
1475
|
+
* already (relationOptionWarning), so only a type without a built-in list is asked about.
|
|
1476
|
+
*/
|
|
1477
|
+
function customOptionWarningJs(relationType, params, key, injected = []) {
|
|
1478
|
+
if (typeof relationType !== 'string' || RELATION_PARAM_KEYS[relationType] || !params || typeof params !== 'object')
|
|
1479
|
+
return '';
|
|
1480
|
+
const keys = Object.keys(params);
|
|
1481
|
+
if (!keys.length)
|
|
1482
|
+
return '';
|
|
1483
|
+
return `, ...(function () { try {
|
|
1484
|
+
var cr = typeof window !== 'undefined' && window.__ppCustomRelations && window.__ppCustomRelations[${jsLiteral(relationType)}];
|
|
1485
|
+
if (!cr || !Array.isArray(cr.reads)) return {};
|
|
1486
|
+
var skip = ${JSON.stringify(injected)};
|
|
1487
|
+
var un = ${JSON.stringify(keys)}.filter(function (k) { return cr.reads.indexOf(k) < 0 && skip.indexOf(k) < 0; });
|
|
1488
|
+
if (!un.length) return {};
|
|
1489
|
+
return { ${key}: ${jsLiteral(relationType)} + ' does not read ' + un.join(', ') + ', so ' + (un.length > 1 ? 'they were' : 'it was') + ' ignored. ' + ${jsLiteral(relationType)} + ' reads: ' + cr.reads.join(', ') + '.' };
|
|
1490
|
+
} catch (e) { return {}; } })()`;
|
|
1491
|
+
}
|
|
1375
1492
|
export function relationOptionWarning(relationType, params, injected = []) {
|
|
1376
1493
|
const flip = equationFlipNote(relationType, params);
|
|
1377
1494
|
const unread = unreadOptionWarning(relationType, params, injected);
|
|
@@ -1393,14 +1510,26 @@ function equationFlipNote(relationType, params) {
|
|
|
1393
1510
|
}
|
|
1394
1511
|
function unreadOptionWarning(relationType, params, injected) {
|
|
1395
1512
|
const keys = typeof relationType === 'string' ? RELATION_PARAM_KEYS[relationType] : undefined;
|
|
1396
|
-
|
|
1513
|
+
return keys ? unreadKeysWarning(String(relationType), keys, params, injected) : null;
|
|
1514
|
+
}
|
|
1515
|
+
/** The keys of `params` outside `keys`, named with a "did you mean" and the list that is read; null when all are read. */
|
|
1516
|
+
function unreadKeysWarning(reader, keys, params, injected = []) {
|
|
1517
|
+
if (!params || typeof params !== 'object')
|
|
1397
1518
|
return null;
|
|
1398
1519
|
const unknown = Object.keys(params).filter((k) => !keys.includes(k) && !injected.includes(k));
|
|
1399
1520
|
if (!unknown.length)
|
|
1400
1521
|
return null;
|
|
1401
1522
|
const norm = (x) => x.replace(/_/g, '').toLowerCase();
|
|
1402
1523
|
const hint = unknown.map((k) => keys.find((x) => norm(x) === norm(k)) ?? keys.find((x) => editDistance(norm(x), norm(k)) <= (k.length > 5 ? 2 : 1))).map((x, i) => (x ? `${unknown[i]} → ${x}?` : '')).filter(Boolean);
|
|
1403
|
-
return `${
|
|
1524
|
+
return `${reader} does not read ${unknown.join(', ')}, so ${unknown.length > 1 ? 'they were' : 'it was'} ignored${hint.length ? ` (${hint.join('; ')})` : ''}. ${reader} reads: ${keys.join(', ')}.`;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* An animationOptions key app.animate never reads (ANIMATION_OPTION_KEYS, generated from the engine). The batch hands
|
|
1528
|
+
* the options over as they are, so an unknown key was dropped with the op reported done. pinepaper_animate's own
|
|
1529
|
+
* names (intensity, speed …) are translated before this (batch-op-names.ts), so they never reach it.
|
|
1530
|
+
*/
|
|
1531
|
+
export function animationOptionWarning(options) {
|
|
1532
|
+
return unreadKeysWarning('animationOptions', ANIMATION_OPTION_KEYS, options);
|
|
1404
1533
|
}
|
|
1405
1534
|
const VISIBILITY_REACTION = /^on_(event|enter|exit)_set_visibility$/;
|
|
1406
1535
|
/**
|
|
@@ -1439,7 +1568,7 @@ String(jsLiteral(targetId)) +
|
|
|
1439
1568
|
", " +
|
|
1440
1569
|
String(jsLiteral(relationType)) +
|
|
1441
1570
|
", " +
|
|
1442
|
-
String(JSON.stringify(params, null, 2)) + ");\n if (!success) {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n return { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String((() => { const w = relationOptionWarning(relationType, params); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + " };\n})();\n").trim();
|
|
1571
|
+
String(JSON.stringify(params, null, 2)) + ");\n if (!success) {\n const __why = " + String(relationRefusalReasonJs(jsLiteral(relationType))) + ";\n return { success: false, relationType: " + String(jsLiteral(relationType)) + ", error: 'the studio refused the ' + " + String(jsLiteral(relationType)) + " + ' relation between ' + " + String(jsLiteral(sourceId)) + " + ' and ' + " + String(jsLiteral(targetId)) + " + (__why ? ': ' + __why : '.') };\n }\n app.historyManager.saveState();\n return { success: true, sourceId: " + String(jsLiteral(sourceId)) + ", targetId: " + String(jsLiteral(targetId)) + ", relationType: " + String(jsLiteral(relationType)) + String((() => { const w = relationOptionWarning(relationType, params); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + String(customOptionWarningJs(relationType, params, 'warning')) + " };\n})();\n").trim();
|
|
1443
1572
|
}
|
|
1444
1573
|
/**
|
|
1445
1574
|
* The per-burst particle cap of the CURRENT studio (FxTool ParticleEmitter
|
|
@@ -1546,13 +1675,14 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
|
|
|
1546
1675
|
mismatch(`bursts[${i}]`, own.count ?? out.count, own.velocities ?? out.velocities);
|
|
1547
1676
|
});
|
|
1548
1677
|
// A RANGE WHERE THE STUDIO READS ONE NUMBER is not drawn as a range (1.6.24
|
|
1549
|
-
// probe:
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
|
|
1678
|
+
// probe: count [a, b] → 0 on older studios, the middle on FxTool #228). Only
|
|
1679
|
+
// the keys that take one number on EVERY build are named here. gravity, drag,
|
|
1680
|
+
// friction, opacity, sizeEnd and jitter were too, until the studio began to
|
|
1681
|
+
// sample them per particle: the tester measured drag 101–206 against this text
|
|
1682
|
+
// saying one value. What a given studio does with a range on those is its own
|
|
1683
|
+
// to say (emitter-range-not-read, in engineWarnings), so it is true for
|
|
1684
|
+
// whichever studio the server is pointed at.
|
|
1685
|
+
const SCALAR_ONLY = ['fadeIn', 'fadeOut', 'strokeWidth', 'jitterHz', 'aspect', 'count'];
|
|
1556
1686
|
const SPEC_ONLY = ['rate', 'start', 'end', 'seed'];
|
|
1557
1687
|
const OUTCOME = {
|
|
1558
1688
|
count: 'an older studio reads a range here as 0 and draws no particles',
|
|
@@ -1561,7 +1691,7 @@ export function normalizeEmitterSpec(spec, assumeDefaultCount = true) {
|
|
|
1561
1691
|
for (const k of keys) {
|
|
1562
1692
|
const v = o[k];
|
|
1563
1693
|
if (Array.isArray(v) && v.length >= 2) {
|
|
1564
|
-
warnings.push(`${where}.${k} ${JSON.stringify(v)} is not drawn as a range: ${k} takes one number, so the studio draws one value for every particle (its default, or the middle of the range, depending on the build${OUTCOME[k] ? `; ${OUTCOME[k]}` : ''}). Pass the single value you want
|
|
1694
|
+
warnings.push(`${where}.${k} ${JSON.stringify(v)} is not drawn as a range: ${k} takes one number, so the studio draws one value for every particle (its default, or the middle of the range, depending on the build${OUTCOME[k] ? `; ${OUTCOME[k]}` : ''}). Pass the single value you want.`);
|
|
1565
1695
|
}
|
|
1566
1696
|
}
|
|
1567
1697
|
const f = o.fade;
|
|
@@ -1770,7 +1900,7 @@ const formatted = relations.map(r => ({
|
|
|
1770
1900
|
// this tool even though it was wired correctly on the canvas.
|
|
1771
1901
|
targetId: r.target ?? r.targetId ?? null,
|
|
1772
1902
|
relationType: r.relation || r.type,
|
|
1773
|
-
params: r.params
|
|
1903
|
+
params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
|
|
1774
1904
|
}));
|
|
1775
1905
|
|
|
1776
1906
|
({ relations: formatted });
|
|
@@ -1790,7 +1920,7 @@ const relations = results.map(r => ({
|
|
|
1790
1920
|
// the filter string labelled every edge 'unknown' on an unfiltered query —
|
|
1791
1921
|
// which is exactly the query you make when exploring an unfamiliar graph.
|
|
1792
1922
|
relationType: r.relation || ${relationType ? jsLiteral(relationType) : "'unknown'"},
|
|
1793
|
-
params: r.params
|
|
1923
|
+
params: ${RELATION_PUBLIC_PARAMS_JS}(r.params)
|
|
1794
1924
|
}));
|
|
1795
1925
|
|
|
1796
1926
|
({ relations });
|
|
@@ -1944,6 +2074,48 @@ function unanimatedTextKeysJs(keys) {
|
|
|
1944
2074
|
}
|
|
1945
2075
|
/** The sentence for the keys unanimatedTextKeysJs found, built in the page. */
|
|
1946
2076
|
const UNANIMATED_TEXT_KEYS_SENTENCE = `' is not an animatable property in this studio (app.listAnimatableProperties does not list it), so it had no effect: a key animates only where that list names it. Here, set a fixed value with pinepaper_modify_item, or animate fontSize or scaleX. For a skew, key skewX / skewY (degrees) where the studio lists them, else a transform matrix track.'`;
|
|
2077
|
+
/**
|
|
2078
|
+
* The warnings a keyframe track's result carries, as object-literal fields (", key: value" …): keys that leave out a
|
|
2079
|
+
* property, loop in an export, x beside fontSize on an older studio, easing on the first key. One function for
|
|
2080
|
+
* pinepaper_keyframe_animate and the batch keyframe_animate op: the batch had its own copy of the emitter and none
|
|
2081
|
+
* of these (SIZEKEY-PAIR: the same keys warned through the tool and not through the batch).
|
|
2082
|
+
*/
|
|
2083
|
+
function keyframeResultNotes(keyframes, { loop = false, append = false, easingAt } = {}) {
|
|
2084
|
+
// A KEY THAT LEAVES A PROPERTY OUT (round 9 HH, engine 2.30): the engine
|
|
2085
|
+
// treats the gap as a break in that property's track rather than holding or
|
|
2086
|
+
// interpolating across it, silently. Named here — which keys lack which
|
|
2087
|
+
// properties — with the fix a caller can make now: give every key the full set.
|
|
2088
|
+
const propSets = keyframes.map((k) => Object.keys(k.properties ?? {}));
|
|
2089
|
+
const allProps = [...new Set(propSets.flat())];
|
|
2090
|
+
const gaps = keyframes
|
|
2091
|
+
.map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
|
|
2092
|
+
.filter((g) => g.missing.length);
|
|
2093
|
+
// With append the gaps that matter are the MERGED track's (an earlier key
|
|
2094
|
+
// with no scale beside a new key with one), so that check runs in the page
|
|
2095
|
+
// on the result instead (below).
|
|
2096
|
+
const gapNote = !append && gaps.length && keyframes.length > 1
|
|
2097
|
+
? `, propertyGaps: ${JSON.stringify(gaps.slice(0, 10))}, gapWarning: ${JSON.stringify(`${gaps.length} keyframe(s) leave out a property other keys animate (${[...new Set(gaps.flatMap((g) => g.missing))].join(', ')}). The engine can break that property's track at such a key; repeat the value on every key — hold it by repeating the previous value.`)}`
|
|
2098
|
+
: '';
|
|
2099
|
+
const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
|
|
2100
|
+
// loop:true repeats in the editor; an export of the measured build played
|
|
2101
|
+
// the keyframes ONCE and held (round 7, 2.24). Said where it is set, with
|
|
2102
|
+
// the two ways that do export, until the engine's export clock wraps them.
|
|
2103
|
+
const loopNote = loop
|
|
2104
|
+
? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
|
|
2105
|
+
: '';
|
|
2106
|
+
// SIZEKEY-PAIR (mcp-cloud #28): on a studio before FxTool #262, x (or
|
|
2107
|
+
// position) keyed beside fontSize on one text item drifted in an export: the
|
|
2108
|
+
// size was applied after the position, and a text resizes around its anchor.
|
|
2109
|
+
// Said there, with the two ways that hold; a current studio is quiet.
|
|
2110
|
+
const sizekeyPairNote = (allProps.includes('x') || allProps.includes('position')) && allProps.includes('fontSize')
|
|
2111
|
+
// A studio that writes text size before position (FxTool #262, which also brought _applyScaleParams) holds x.
|
|
2112
|
+
? `, ...(typeof app._applyScaleParams !== 'function' ? { sizekeyPairWarning: ${JSON.stringify('x (or position) and fontSize keyed on the same text item drift off the keyed position in an export on this studio (SIZEKEY-PAIR). Animate scaleX / scaleY instead of fontSize, or move a group that holds the text; or update the studio.')} } : {})`
|
|
2113
|
+
: '';
|
|
2114
|
+
const easingNote = easingAt !== 'departure' && first?.easing && first.easing !== 'linear'
|
|
2115
|
+
? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect by default: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to, or pass easingAt: 'departure' to read every easing as the move LEAVING its key (current studio).`)}`
|
|
2116
|
+
: '';
|
|
2117
|
+
return `${easingNote}${sizekeyPairNote}${loopNote}${gapNote}`;
|
|
2118
|
+
}
|
|
1947
2119
|
function generateKeyframeAnimateCode(itemId, keyframes, duration, loop = false, timeOffset, clipInPoint, clipOutPoint, timeUnits, append = false, easingAt) {
|
|
1948
2120
|
const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
|
|
1949
2121
|
const keyframesJson = JSON.stringify(normalizeKeyframePositions(keyframes), null, 2);
|
|
@@ -1995,39 +2167,7 @@ String(JSON.stringify(levelKeys)) +
|
|
|
1995
2167
|
// never applies — a Ken Burns eased only there rendered linear. Said in the
|
|
1996
2168
|
// result rather than silently moved: moving it would change what the other
|
|
1997
2169
|
// keys the caller wrote mean.
|
|
1998
|
-
|
|
1999
|
-
// treats the gap as a break in that property's track rather than holding or
|
|
2000
|
-
// interpolating across it, silently. Named here — which keys lack which
|
|
2001
|
-
// properties — with the fix a caller can make now: give every key the full set.
|
|
2002
|
-
const propSets = keyframes.map((k) => Object.keys(k.properties ?? {}));
|
|
2003
|
-
const allProps = [...new Set(propSets.flat())];
|
|
2004
|
-
const gaps = keyframes
|
|
2005
|
-
.map((k, i) => ({ time: k.time, missing: allProps.filter((p) => !propSets[i].includes(p)) }))
|
|
2006
|
-
.filter((g) => g.missing.length);
|
|
2007
|
-
// With append the gaps that matter are the MERGED track's (an earlier key
|
|
2008
|
-
// with no scale beside a new key with one), so that check runs in the page
|
|
2009
|
-
// on the result instead (below).
|
|
2010
|
-
const gapNote = !append && gaps.length && keyframes.length > 1
|
|
2011
|
-
? `, propertyGaps: ${JSON.stringify(gaps.slice(0, 10))}, gapWarning: ${JSON.stringify(`${gaps.length} keyframe(s) leave out a property other keys animate (${[...new Set(gaps.flatMap((g) => g.missing))].join(', ')}). The engine can break that property's track at such a key; repeat the value on every key — hold it by repeating the previous value.`)}`
|
|
2012
|
-
: '';
|
|
2013
|
-
const first = [...keyframes].sort((a, b) => a.time - b.time)[0];
|
|
2014
|
-
// loop:true repeats in the editor; an export of the measured build played
|
|
2015
|
-
// the keyframes ONCE and held (round 7, 2.24). Said where it is set, with
|
|
2016
|
-
// the two ways that do export, until the engine's export clock wraps them.
|
|
2017
|
-
const loopNote = loop
|
|
2018
|
-
? `, loopNote: ${JSON.stringify('loop:true repeats in the editor, but has been measured playing ONCE in an exported video and then holding the last keyframe. For an export longer than one cycle, check a frame in the second cycle — if it holds, write the keyframes out per cycle, or use a loop preset (pinepaper_animate), which does cycle in export.')}`
|
|
2019
|
-
: '';
|
|
2020
|
-
// SIZEKEY-PAIR (mcp-cloud #28): on a studio before FxTool #262, x (or
|
|
2021
|
-
// position) keyed beside fontSize on one text item drifted in an export: the
|
|
2022
|
-
// size was applied after the position, and a text resizes around its anchor.
|
|
2023
|
-
// Said there, with the two ways that hold; a current studio is quiet.
|
|
2024
|
-
const sizekeyPairNote = (allProps.includes('x') || allProps.includes('position')) && allProps.includes('fontSize')
|
|
2025
|
-
// A studio that writes text size before position (FxTool #262, which also brought _applyScaleParams) holds x.
|
|
2026
|
-
? `, ...(typeof app._applyScaleParams !== 'function' ? { sizekeyPairWarning: ${JSON.stringify('x (or position) and fontSize keyed on the same text item drift off the keyed position in an export on this studio (SIZEKEY-PAIR). Animate scaleX / scaleY instead of fontSize, or move a group that holds the text; or update the studio.')} } : {})`
|
|
2027
|
-
: '';
|
|
2028
|
-
const easingNote = easingAt !== 'departure' && first?.easing && first.easing !== 'linear'
|
|
2029
|
-
? `, note: ${JSON.stringify(`easing '${first.easing}' is on the first keyframe, where it has no effect by default: easing shapes the segment ARRIVING at a keyframe. Put it on the keyframe you are moving to, or pass easingAt: 'departure' to read every easing as the move LEAVING its key (current studio).`)}`
|
|
2030
|
-
: '';
|
|
2170
|
+
const __notes = keyframeResultNotes(keyframes, { loop, append, easingAt });
|
|
2031
2171
|
const textKeysJs = unanimatedTextKeysJs(keyframes.flatMap((k) => Object.keys(k.properties ?? {})));
|
|
2032
2172
|
const textKeyCheck = textKeysJs ? `
|
|
2033
2173
|
const __ignored = ${textKeysJs};
|
|
@@ -2070,7 +2210,7 @@ String(calculatedDuration) +
|
|
|
2070
2210
|
String(loop) +
|
|
2071
2211
|
String(append ? ', append: true' : '') +
|
|
2072
2212
|
", ...(__track ? { track: __track } : {})" +
|
|
2073
|
-
String(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.' } : {})` : '') + String(timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : '') + String(clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : '') + String(clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : '') + String(
|
|
2213
|
+
String(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.' } : {})` : '') + String(timeOffset !== undefined ? `, timeOffset: ${timeOffset}` : '') + String(clipInPoint !== undefined ? `, clipInPoint: ${clipInPoint}` : '') + String(clipOutPoint !== undefined ? `, clipOutPoint: ${clipOutPoint}` : '') + String(__notes) + String(runtimeWarning) + " };\n})();\n").trim();
|
|
2074
2214
|
}
|
|
2075
2215
|
/**
|
|
2076
2216
|
* Template for executing generators
|
|
@@ -2204,6 +2344,29 @@ function withRadiusAxes(props) {
|
|
|
2204
2344
|
out.height = radiusY * 2;
|
|
2205
2345
|
return out;
|
|
2206
2346
|
}
|
|
2347
|
+
/**
|
|
2348
|
+
* One get_items entry. What create applied is read back here, so an agent can check it: the name a patch addresses
|
|
2349
|
+
* the item by, and scale, rotation and opacity when they are not the default (CROWSCALE30: a create-time scale
|
|
2350
|
+
* rendered exactly and get_items did not show it; PATCHIMG29: an import's name was not shown either). Defaults are
|
|
2351
|
+
* left out, so a plain item's entry stays as small as before.
|
|
2352
|
+
*/
|
|
2353
|
+
const GET_ITEMS_ENTRY_JS = ("entry => {\n const it = entry.item || {};\n const out = {\n id: entry.id || entry.itemId,\n type: entry.type,\n position: it.position ? { x: it.position.x, y: it.position.y } : null,\n hasAnimation: !!((it.data?.animationType && it.data.animationType !== 'none') || (Array.isArray(it.data?.keyframes) && it.data.keyframes.length > 0)),\n relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)\n };\n const name = it.name || entry.name || (it.data && it.data.name);\n if (name) out.name = name;\n const r2 = (v) => Math.round(v * 1000) / 1000;\n if (it.scaling && (Math.abs(it.scaling.x - 1) > 1e-6 || Math.abs(it.scaling.y - 1) > 1e-6)) out.scale" +
|
|
2354
|
+
" = { x: r2(it.scaling.x), y: r2(it.scaling.y) };\n if (typeof it.rotation === 'number' && Math.abs(it.rotation) > 1e-6) out.rotation = r2(it.rotation);\n if (typeof it.opacity === 'number' && it.opacity !== 1) out.opacity = r2(it.opacity);\n return out;\n}"
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
|
|
2369
|
+
);
|
|
2207
2370
|
function generateGetItemsCode(filter) {
|
|
2208
2371
|
// A filter OBJECT is not the same as a filter. `{}` is already handled, but
|
|
2209
2372
|
// so is `{ type: undefined }` and anything whose keys this function does not
|
|
@@ -2228,13 +2391,7 @@ function generateGetItemsCode(filter) {
|
|
|
2228
2391
|
return `
|
|
2229
2392
|
// Get all items
|
|
2230
2393
|
const entries = app.itemRegistry.getAll();
|
|
2231
|
-
const items = entries.map(
|
|
2232
|
-
id: entry.id || entry.itemId,
|
|
2233
|
-
type: entry.type,
|
|
2234
|
-
position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
|
|
2235
|
-
hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
|
|
2236
|
-
relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
|
|
2237
|
-
}));
|
|
2394
|
+
const items = entries.map(${GET_ITEMS_ENTRY_JS});
|
|
2238
2395
|
|
|
2239
2396
|
({ items, count: items.length });
|
|
2240
2397
|
`.trim();
|
|
@@ -2243,13 +2400,7 @@ const items = entries.map(entry => ({
|
|
|
2243
2400
|
// Get items with filter
|
|
2244
2401
|
const entries = app.itemRegistry.getAll();
|
|
2245
2402
|
const filtered = entries.filter(entry => ${conditions.join(' && ')});
|
|
2246
|
-
const items = filtered.map(
|
|
2247
|
-
id: entry.id || entry.itemId,
|
|
2248
|
-
type: entry.type,
|
|
2249
|
-
position: entry.item.position ? { x: entry.item.position.x, y: entry.item.position.y } : null,
|
|
2250
|
-
hasAnimation: !!((entry.item.data?.animationType && entry.item.data.animationType !== 'none') || (Array.isArray(entry.item.data?.keyframes) && entry.item.data.keyframes.length > 0)),
|
|
2251
|
-
relations: app.getRelations(entry.id || entry.itemId).map(r => r.relation || r.type)
|
|
2252
|
-
}));
|
|
2403
|
+
const items = filtered.map(${GET_ITEMS_ENTRY_JS});
|
|
2253
2404
|
|
|
2254
2405
|
({ items, count: items.length });
|
|
2255
2406
|
`.trim();
|
|
@@ -2500,7 +2651,12 @@ function generateClearCanvasCode() {
|
|
|
2500
2651
|
String(MEDIA_PURGE) +
|
|
2501
2652
|
"\n\nif (typeof app.clearCanvas === 'function') {\n app.clearCanvas();\n} else {\n // Fallback for older builds without clearCanvas().\n if (app.textItemGroup) app.textItemGroup.removeChildren();\n if (app.patternGroup) app.patternGroup.removeChildren();\n if (app.itemRegistry) app.itemRegistry.clear();\n if (app.relationRegistry) app.relationRegistry.clear();\n}\n" +
|
|
2502
2653
|
String(ID_COUNTER_RESET) +
|
|
2503
|
-
"\n\nif (app.historyManager) app.historyManager.saveState();\n\nconst remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;\n\n({ success: true, removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });\n"
|
|
2654
|
+
"\n// A rule outlives app.clearCanvas, and would go on running in the next scene against its item ids.\nlet __rulesRemoved = 0;\nif (typeof app.listContraptions === 'function' && typeof app.removeContraption === 'function') {\n app.listContraptions().map((c) => c.id).forEach((id) => { if (app.removeContraption(id)) __rulesRemoved++; });\n}\n\nif (app.historyManager) app.historyManager.saveState();\n\nconst remaining = app.itemRegistry ? app.itemRegistry.getAll().length : 0;\n\n({ success: true, ...(__rulesRemoved ? { contraptionsRemoved: __rulesRemoved } : {}), removedCount: Math.max(0, before - remaining), remainingItems: remaining, mediaRemoved: __mediaRemoved });\n"
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
|
|
2659
|
+
|
|
2504
2660
|
|
|
2505
2661
|
|
|
2506
2662
|
|
|
@@ -3385,14 +3541,22 @@ String(JSON.stringify(validated.presetId)) +
|
|
|
3385
3541
|
).trim();
|
|
3386
3542
|
}
|
|
3387
3543
|
{
|
|
3388
|
-
const problem = relationSpecProblem(validated.relationType, validated.params);
|
|
3544
|
+
const problem = relationSpecProblem(validated.relationType, validated.params, validated.targetId);
|
|
3389
3545
|
if (problem)
|
|
3390
3546
|
return `(function() { return { success: false, relationType: ${jsLiteral(validated.relationType)}, error: ${jsLiteral(problem)} }; })();`;
|
|
3391
3547
|
}
|
|
3392
3548
|
if (validated.relationType === 'moves_along_path') {
|
|
3393
3549
|
return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
|
|
3394
3550
|
}
|
|
3395
|
-
const
|
|
3551
|
+
const built = generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
|
|
3552
|
+
// Target-less step_active: refused in the page, only on a studio that would drop it (targetlessStepProblem).
|
|
3553
|
+
const stepNoTarget = targetlessStepProblem(validated.relationType, validated.targetId);
|
|
3554
|
+
const code = stepNoTarget
|
|
3555
|
+
? `(function() {
|
|
3556
|
+
if (!${STEP_ACTIVE_TARGETLESS_JS}) return { success: false, relationType: 'on_event_step_active', error: ${jsLiteral(stepNoTarget)} };
|
|
3557
|
+
return ${built.trim().replace(/;$/, '')};
|
|
3558
|
+
})();`
|
|
3559
|
+
: built;
|
|
3396
3560
|
// NO TARGET (event reactions like on_event_play_sound, self-relations): the
|
|
3397
3561
|
// engine is handed the source as the target, but the reply must not say
|
|
3398
3562
|
// "item_2 → item_2" — a model reads that as the event wired to itself.
|
|
@@ -4073,7 +4237,7 @@ if (typeof _gen === 'function') {
|
|
|
4073
4237
|
for (const relation of relations) {
|
|
4074
4238
|
const { source, target, type, params = {} } = relation;
|
|
4075
4239
|
const paramsStr = JSON.stringify(params);
|
|
4076
|
-
const specProblem = relationSpecProblem(type, params);
|
|
4240
|
+
const specProblem = relationSpecProblem(type, params, target, 'no-page');
|
|
4077
4241
|
if (specProblem) {
|
|
4078
4242
|
codeParts.push(`
|
|
4079
4243
|
// Refused relation: ${oneLine(source)} ${oneLine(type)} ${oneLine(target)}
|
|
@@ -5070,13 +5234,16 @@ const targetId = ${animItemRef};
|
|
|
5070
5234
|
const item = app.getItemById(targetId);
|
|
5071
5235
|
if (!item) throw new Error('Item not found: ' + targetId);
|
|
5072
5236
|
app.animate(item, { animationType: ${JSON.stringify(String(op.animationType))}, ...${animOpts} });
|
|
5073
|
-
return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationType))} };
|
|
5237
|
+
return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationType))}${(() => { const w = animationOptionWarning(op.animationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()} };
|
|
5074
5238
|
`;
|
|
5075
5239
|
case 'relation':
|
|
5076
5240
|
const srcRef = batchRef(op.sourceId);
|
|
5077
5241
|
const tgtRef = batchRef(op.targetId);
|
|
5078
5242
|
const relOpts = JSON.stringify(op.relationOptions || {});
|
|
5079
|
-
const fireIf = relationSpecProblem(op.relationType, op.relationOptions);
|
|
5243
|
+
const fireIf = relationSpecProblem(op.relationType, op.relationOptions, op.targetId);
|
|
5244
|
+
// Target-less step_active: refused in the page, only on a studio that would drop it (targetlessStepProblem).
|
|
5245
|
+
const stepNoTarget = targetlessStepProblem(op.relationType, op.targetId);
|
|
5246
|
+
const stepGuard = stepNoTarget ? `if (!${STEP_ACTIVE_TARGETLESS_JS}) throw new Error(${jsLiteral('on_event_step_active: ' + stepNoTarget)});\n` : '';
|
|
5080
5247
|
if (fireIf)
|
|
5081
5248
|
return `
|
|
5082
5249
|
throw new Error(${JSON.stringify(String(op.relationType) + ': ' + fireIf)});
|
|
@@ -5100,6 +5267,7 @@ if (!__members.length) throw new Error('no item is named like ' + ${jsLiteral(op
|
|
|
5100
5267
|
: `const __members = [{ id: ${srcRef} }];
|
|
5101
5268
|
if (!app.getItemById(__members[0].id)) throw new Error('Source not found: ' + __members[0].id);`;
|
|
5102
5269
|
return ("\n" +
|
|
5270
|
+
String(stepGuard) +
|
|
5103
5271
|
String(members) +
|
|
5104
5272
|
"\nconst targetId = " +
|
|
5105
5273
|
String(noTarget ? 'null' : tgtRef) +
|
|
@@ -5127,10 +5295,11 @@ String(type) +
|
|
|
5127
5295
|
", sourceIds: __failed, error: " +
|
|
5128
5296
|
String(op.update === true ? '__why' : `'the studio refused the ' + ${type} + ' relation for ' + __failed.join(', ') + ' — nothing was bound' + (__why ? ': ' + __why : '.')`) +
|
|
5129
5297
|
" };\n}\nreturn { success: true, relationType: " +
|
|
5130
|
-
String(type) +
|
|
5131
|
-
", " + String(op.update === true ? 'updated' : 'bound') + ": __done, targetId" + String(glob ? ', count: __members.length' : '') + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions, ['index', 'count', 'pattern']); return w ? `, optionWarning: ${jsLiteral(w)}` : ''; })()) + ", ...(__failed.length ? { failed: __failed, warning: __failed.length + ' of ' + __members.length + ' were not " + String(op.update === true ? 'changed' : 'bound') + ": ' + __failed.join(', ') } : {}) };\n");
|
|
5298
|
+
String(type) + ", " + String(op.update === true ? 'updated' : 'bound') + ": __done, targetId" + String(glob ? ', count: __members.length' : '') + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions, ['index', 'count', 'pattern']); return w ? `, optionWarning: ${jsLiteral(w)}` : ''; })()) + String(customOptionWarningJs(op.relationType, op.relationOptions, 'optionWarning', ['index', 'count', 'pattern'])) + ", ...(__failed.length ? { failed: __failed, warning: __failed.length + ' of ' + __members.length + ' were not " + String(op.update === true ? 'changed' : 'bound') + ": ' + __failed.join(', ') } : {}) };\n");
|
|
5132
5299
|
}
|
|
5133
|
-
return ("\
|
|
5300
|
+
return ("\n" +
|
|
5301
|
+
String(stepGuard) +
|
|
5302
|
+
"const sourceId = " +
|
|
5134
5303
|
String(srcRef) +
|
|
5135
5304
|
";\nconst targetId = " +
|
|
5136
5305
|
String(noTarget ? 'null' : tgtRef) +
|
|
@@ -5145,9 +5314,7 @@ String(relOpts) +
|
|
|
5145
5314
|
");\n// NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false\n// when it refuses — spring_follow \"not loaded yet\" — and this op returned\n// { relationId: false } with nothing marking it failed, so the batch said\n// success over a relation that does not exist.\nif (!relationId) {\n const __why = " +
|
|
5146
5315
|
String(relationRefusalReasonJs(jsLiteral(op.relationType))) +
|
|
5147
5316
|
";\n return { success: false, relationId: false, sourceId, targetId, relationType: " +
|
|
5148
|
-
String(JSON.stringify(op.relationType)) +
|
|
5149
|
-
",\n error: 'the studio refused the ' + " +
|
|
5150
|
-
String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + " };\n");
|
|
5317
|
+
String(JSON.stringify(op.relationType)) + ",\n error: 'the studio refused the ' + " + String(JSON.stringify(op.relationType)) + " + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };\n}\nreturn { relationId, sourceId, targetId, relationType: " + String(JSON.stringify(String(op.relationType))) + String((() => { const w = relationOptionWarning(op.relationType, op.relationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()) + String(customOptionWarningJs(op.relationType, op.relationOptions, 'warning')) + " };\n");
|
|
5151
5318
|
case 'delete':
|
|
5152
5319
|
const delRef = batchRef(op.itemId);
|
|
5153
5320
|
return `
|
|
@@ -5248,7 +5415,7 @@ String(kfLoop) +
|
|
|
5248
5415
|
String(kfAppend ? ", mode: 'merge'" : '') +
|
|
5249
5416
|
String(kfExtraStr) +
|
|
5250
5417
|
" });" +
|
|
5251
|
-
String(kfTextCheck) + "\nreturn { success: true, itemId: targetId, duration: " + String(kfDuration) + ", loop: " + String(kfLoop) + String(kfAppend ? ', append: true' : '') + String(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.' } } : {})`) + String(kfIgnored) + " };\n");
|
|
5418
|
+
String(kfTextCheck) + "\nreturn { success: true, itemId: targetId, duration: " + String(kfDuration) + ", loop: " + String(kfLoop) + String(kfAppend ? ', append: true' : '') + String(keyframeResultNotes(kfSource, { loop: kfLoop === true, append: kfAppend })) + String(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.' } } : {})`) + String(kfIgnored) + " };\n");
|
|
5252
5419
|
}
|
|
5253
5420
|
case 'apply_mask': {
|
|
5254
5421
|
const maskItemRef = batchRef(op.itemId);
|
|
@@ -5376,7 +5543,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
5376
5543
|
}
|
|
5377
5544
|
generateAgentExport(input) {
|
|
5378
5545
|
const validated = AgentExportInputSchema.parse(input);
|
|
5379
|
-
const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, transparent, alphaQuantizer, deterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
|
|
5546
|
+
const { platform, format, quality, framing, duration, estimateOnly, scale, fps, pdf: pdfOpts, region, time: stillTime, maxBytes, loop: gifLoop, broadcast, broadcastHeadroom, bitrate, minBitrate, bitrateMode, audioCodec, transparent, alphaQuantizer, deterministic, motionBlur, ad: adOpts, frames, startTime } = validated;
|
|
5380
5547
|
// Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
|
|
5381
5548
|
const videoEncodeOpts = {};
|
|
5382
5549
|
// A RANGE of the timeline (FxTool #250): frames for t in [startTime, startTime + duration), each exactly the frame
|
|
@@ -5398,8 +5565,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
|
|
|
5398
5565
|
videoEncodeOpts.minBitrate = minBitrate;
|
|
5399
5566
|
if (bitrateMode !== undefined)
|
|
5400
5567
|
videoEncodeOpts.bitrateMode = bitrateMode;
|
|
5401
|
-
//
|
|
5402
|
-
//
|
|
5568
|
+
// B45 (FxTool #288): 'aac' makes the studio refuse an MP4 whose soundtrack would be Opus (code
|
|
5569
|
+
// audio_codec_unavailable) rather than write a file WhatsApp rejects. 'any' is the engine's default, so not sent.
|
|
5570
|
+
if (audioCodec === 'aac')
|
|
5571
|
+
videoEncodeOpts.audioCodec = 'aac';
|
|
5572
|
+
// Determinism (B31, the contract signed off in FxTool #290): identical encoder
|
|
5573
|
+
// input and a container identical apart from the encoded samples; the file
|
|
5574
|
+
// itself repeats only where the studio reports bitExact: true.
|
|
5403
5575
|
if (deterministic)
|
|
5404
5576
|
videoEncodeOpts.deterministic = true;
|
|
5405
5577
|
// Motion blur (FxTool #55): the engine forwards it on every mp4/webm path
|
|
@@ -6033,9 +6205,10 @@ String(videoDuration) +
|
|
|
6033
6205
|
".264 needs even dimensions, so\n // 1200x675 came out 1200x676 — and platform specs are exact-pixel. The\n // result's dimensions become what the file IS, and fidelity says why.\n const __vd = __vr && __vr.dimensions;\n if (result && result.success && __vd && Array.isArray(__vd.requested) && Array.isArray(__vd.output)\n && (__vd.requested[0] !== __vd.output[0] || __vd.requested[1] !== __vd.output[1])) {\n result.dimensions = { width: __vd.output[0], height: __vd.output[1] };\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'dimensions_rounded',\n message: 'the file is ' + __vd.outpu" +
|
|
6034
6206
|
"t[0] + 'x' + __vd.output[1] + ', not the requested ' + __vd.requested[0] + 'x' + __vd.requested[1] + ': H.264 needs even dimensions, so an odd side was rounded up by 1 px (the extra row / column is background). For an exact-pixel spec, use an even canvas size, or crop the extra line when re-encoding (ffmpeg -vf crop=' + __vd.requested[0] + ':' + __vd.requested[1] + ':0:0).' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n" +
|
|
6035
6207
|
String(deterministic ? (" // DETERMINISTIC: the container timestamps are pinned either way; the PIXELS\n // are only identical run to run on the software encoder, because the\n // browser may pick a different hardware backend each time (FxTool\n // e34ae03d: 92/255 between backends, 2/255 run to run on hardware). The\n // engine requests software and reports what it got.\n // Where the studio says outright whether the file repeats (bitExact, FxTool\n // PR #31: it test-encodes and keeps whichever encoder does — often the\n // HARDWARE one on a Mac, since Chrome's software H.264 is not repeatable at\n // some sizes), that answer decides; the encoder name is only the fallback\n // for studios that do not r" +
|
|
6036
|
-
"eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked
|
|
6037
|
-
"result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.
|
|
6038
|
-
"
|
|
6208
|
+
"eport it.\n if (result && result.success) {\n const __hw = __vr && __vr.hardwareAcceleration;\n const __exact = __vr && typeof __vr.bitExact === 'boolean' ? __vr.bitExact : null;\n if (__exact === false) {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n ...(__vr && __vr.cause ? { cause: __vr.cause } : {}),\n message: 'deterministic was asked for, but no encoder on this machine repeats byte for byte at this size (the studio test-encoded and checked' + (__vr && __vr.cause === 'h264-threaded' ? ': the software H.264 encoder is multi-threaded and " +
|
|
6209
|
+
"takes no fixed quantizer' : '') + '). What still holds: the frames fed to the encoder are identical (compare frameDigest, not sha256), and the container is identical apart from the encoded samples. Do not cache this file by hash; export WebM for a byte-identical file.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n } else if (__exact === null && __hw !== 'prefer-software') {\n result.fidelity = result.fidelity || { warnings: [] };\n result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'determinism_not_pinned',\n message: __hw\n ? 'deterministic was asked for, but the encoder ran with hardwareAcceleration ' + JSON.str" +
|
|
6210
|
+
"ingify(__hw) + ': the container is pinned, the pixels may still differ slightly between runs (hardware encoders vary). Do not cache this file by hash.'\n : 'deterministic pinned the container timestamps, but this studio does not report which encoder backend ran, so the pixels may differ slightly between runs if the browser switches between hardware and software encoding. Do not rely on byte-identical files from this studio.' }]);\n if (result.fidelity.note) delete result.fidelity.note;\n }\n }\n"
|
|
6211
|
+
|
|
6039
6212
|
|
|
6040
6213
|
|
|
6041
6214
|
|
|
@@ -7174,6 +7347,7 @@ String(jsLiteral(format)) +
|
|
|
7174
7347
|
|
|
7175
7348
|
|
|
7176
7349
|
|
|
7350
|
+
|
|
7177
7351
|
|
|
7178
7352
|
|
|
7179
7353
|
).trim();
|
|
@@ -8787,7 +8961,7 @@ String(validated.priority || 0) +
|
|
|
8787
8961
|
"\n });\n // The definition as given, kept on the page so a later call can read it back (pinepaper_lint_scene\n // customRelations): the engine keeps the compiled rule, not the text an agent would edit.\n window.__ppCustomRelations = window.__ppCustomRelations || {};\n window.__ppCustomRelations[" +
|
|
8788
8962
|
String(jsLiteral(validated.name)) +
|
|
8789
8963
|
"] = " +
|
|
8790
|
-
String(jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction })) +
|
|
8964
|
+
String(jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction, reads: customRelationReads(validated.params, validated.computeFunction, validated.applyFunction) })) +
|
|
8791
8965
|
";\n return { success: true, relationName: " +
|
|
8792
8966
|
String(jsLiteral(validated.name)) +
|
|
8793
8967
|
", message: 'Custom relation registered successfully' };\n } catch (error) {\n return { success: false, error: error.message };\n }\n})();\n"
|
|
@@ -12605,11 +12779,19 @@ String(sheet.format === 'png' ? '' : ', 0.82') +
|
|
|
12605
12779
|
// A fire_if the doc declares is checked before anything is built: refused, the whole document is, with every reason.
|
|
12606
12780
|
const specProblems = ops
|
|
12607
12781
|
.filter((o) => o.op === 'addRelation')
|
|
12608
|
-
.map((o) => { const r = o; const why = relationSpecProblem(r.relation, r.params); return why ? `${r.from} -> ${r.to} (${r.relation}): ${why}` : null; })
|
|
12782
|
+
.map((o) => { const r = o; const why = relationSpecProblem(r.relation, r.params, r.to); return why ? `${r.from} -> ${r.to} (${r.relation}): ${why}` : null; })
|
|
12609
12783
|
.filter((x) => !!x);
|
|
12610
12784
|
if (specProblems.length) {
|
|
12611
12785
|
return `(function() { return { success: false, error: ${jsLiteral('nothing was built: ' + specProblems.join(' | '))} }; })();`;
|
|
12612
12786
|
}
|
|
12787
|
+
// Target-less step_active relations refuse the whole document in the page, only on a studio that would drop them.
|
|
12788
|
+
const stepNoTarget = ops
|
|
12789
|
+
.filter((o) => o.op === 'addRelation')
|
|
12790
|
+
.map((o) => { const r = o; const why = targetlessStepProblem(r.relation, r.to); return why ? `${r.from} -> (no target) (${r.relation}): ${why}` : null; })
|
|
12791
|
+
.filter((x) => !!x);
|
|
12792
|
+
const stepDocGuard = stepNoTarget.length
|
|
12793
|
+
? `if (!${STEP_ACTIVE_TARGETLESS_JS}) return { success: false, error: ${jsLiteral('nothing was built: ' + stepNoTarget.join(' | '))} };\n`
|
|
12794
|
+
: '';
|
|
12613
12795
|
const opsJson = JSON.stringify(ops, null, 2);
|
|
12614
12796
|
const diagJson = JSON.stringify(diagnostics);
|
|
12615
12797
|
const docJson = JSON.stringify(input.doc);
|
|
@@ -12621,7 +12803,9 @@ String(sheet.format === 'png' ? '' : ', 0.82') +
|
|
|
12621
12803
|
String(oneLine(ops.filter((o) => o.op === 'create').length)) +
|
|
12622
12804
|
" items, " +
|
|
12623
12805
|
String(oneLine(ops.filter((o) => o.op === 'addRelation').length)) +
|
|
12624
|
-
" relations)\n(async function() {\n
|
|
12806
|
+
" relations)\n(async function() {\n " +
|
|
12807
|
+
String(stepDocGuard) +
|
|
12808
|
+
" // Engine door first: the studio's own OntologyCompiler reads every node facet\n // (geometry, paint, text, the keyframe track, masks) and builds through the\n // template loader's paths — the round trip the studio's pixel tests verify\n // (e2e/graph-instantiate-parity.spec.js). The server-side compile above is\n // kept for the diagnostics preview and for studios that predate the facade,\n // which get the box loop below and a note saying so.\n const doc = " +
|
|
12625
12809
|
String(docJson) +
|
|
12626
12810
|
";\n if (typeof app.instantiateOntology === 'function') {\n // THE LIVE CANVAS, unless one was given: the engine lays out for 1080x1080\n // otherwise, which put items at (540,540) on a 640x360 canvas (gate run 3).\n const __opts = " +
|
|
12627
12811
|
String(engineOptsJson) +
|
|
@@ -12679,8 +12863,6 @@ String(diagJson) +
|
|
|
12679
12863
|
|
|
12680
12864
|
|
|
12681
12865
|
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
12866
|
|
|
12685
12867
|
|
|
12686
12868
|
|
|
@@ -12700,16 +12882,20 @@ String(diagJson) +
|
|
|
12700
12882
|
String(optsJson) +
|
|
12701
12883
|
");\n // THE RELATIONS AS BOUND, so a later agent can find the one to edit without exporting the scene to read it:\n // from, to, type and params, capped. And the custom relation definitions registered on this page, as given.\n const __rels = [];\n let __relCount = 0;\n try {\n const __rr = app.relationRegistry;\n const __active = __rr && typeof __rr.queryActive === 'function' ? __rr.queryActive() : [];\n __active.forEach((e) => (e.associations || []).forEach((a) => {\n __relCount++;\n if (__rels.length >= " +
|
|
12702
12884
|
String(RELATIONS_READBACK_CAP) +
|
|
12703
|
-
") return;\n const __name = e.item && e.item.data && e.item.data.name;\n __rels.push({ from: e.itemId, ...(__name ? { fromName: __name } : {}), to: a.target === undefined ? null : a.target, type: a.relation, params:
|
|
12704
|
-
|
|
12885
|
+
") return;\n const __name = e.item && e.item.data && e.item.data.name;\n __rels.push({ from: e.itemId, ...(__name ? { fromName: __name } : {}), to: a.target === undefined ? null : a.target, type: a.relation, params: " +
|
|
12886
|
+
String(RELATION_PUBLIC_PARAMS_JS) +
|
|
12887
|
+
"(a.params) });\n }));\n } catch (e) { /* the relations are an addition; the lint stands without them */ }\n // CUSTOM RELATIONS THIS SCENE USES (#43): one registered before a clear_canvas stayed in the read-back of the scene\n // after it. Those in use are listed; the rest are named in registeredUnused. Their source (computeFunction /\n // applyFunction) only with withSource: true, since an edit of params never needs it.\n const __usedTypes = new Set();\n try { const __rr2 = app.relationRegistry; (__rr2 && typeof __rr2.queryActive === 'function' ? __rr2.queryActive() : []).forEach((e) => (e.associations || []).forEach((a) => __usedTypes.add(a.relation))); } catch (e) { /* none kno" +
|
|
12888
|
+
"wn */ }\n const __allCustom = (typeof window !== 'undefined' && window.__ppCustomRelations) ? Object.values(window.__ppCustomRelations) : [];\n const __strip = (d) => { if (" +
|
|
12705
12889
|
String(input.withSource === true ? 'true' : 'false') +
|
|
12706
12890
|
") return d; const o = Object.assign({}, d); delete o.computeFunction; delete o.applyFunction; return o; };\n const __custom = __allCustom.filter((d) => __usedTypes.has(d.name)).map(__strip);\n const __unusedCustom = __allCustom.filter((d) => !__usedTypes.has(d.name)).map((d) => d.name);\n // HOW TO CHANGE IT (#43): an editing agent needs the op, not the schemas. Each relation comes with the batch op that\n // changes it; a group bound by a name pattern (\"dot-*\", recorded in each member's params) comes once, with the ops\n // that add and remove a member; a custom motion with the call that redefines it.\n const __edit = { hint: 'To change this scene, send the ops below (your values " +
|
|
12707
|
-
"in relationOptions) in ONE pinepaper_agent_batch_execute call; a custom motion changes by sending its pinepaper_register_custom_relation call again.', groups: [], relations: [], customRelations: [] };\n try {\n const __byPat = new Map();\n __rels.forEach((r) => {\n const pat = r.params && r.params.pattern;\n if (typeof pat === 'string') {\n const k = pat + '|' + r.type + '|' + r.to;\n if (!__byPat.has(k)) __byPat.set(k, { pattern: pat, type: r.type, to: r.to, members: [] });\n __byPat.get(k).members.push(r);\n return;\n }\n __edit.relations.push({ from: r.from, ...(r.fromName ? { fromName: r.
|
|
12708
|
-
"type,\n change: { type: 'relation', sourceId: r.fromName || r.from, ...(r.to != null ? { targetId: r.to } : {}), relationType: r.type, update: true, relationOptions: r.params } });\n });\n __byPat.forEach((g) => {\n const base = Object.assign({}, g.members[0].params); delete base.index; delete base.count; delete base.pattern;\n const names = g.members.map((m) => m.fromName).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));\n const prefix = g.pattern.split('*')[0];\n const nums = names.map((n) => Number(n.slice(prefix.length))).filter((n) => Number.isFinite(n));\n const next =
|
|
12709
|
-
"nums) + 1 : g.members.length + 1);\n const firstEntry = app.itemRegistry.get(g.members[0].from);\n const bind = { type: 'relation', sourceId: g.pattern, ...(g.to != null ? { targetId: g.to } : {}), relationType: g.type, relationOptions: base };\n __edit.groups.push({ pattern: g.pattern, relationType: g.type, members: g.members.length, ...(names.length ? { first: names[0], last: names[names.length - 1] } : {}), relationOptions: base,\n change: Object.assign({}, bind, { update: true }),\n addMember: [{ type: 'create', itemType: (firstEntry && firstEntry.type) || 'circle', position: [0, 0], properties: { name: next } },
|
|
12710
|
-
"
|
|
12891
|
+
"in relationOptions) in ONE pinepaper_agent_batch_execute call; a custom motion changes by sending its pinepaper_register_custom_relation call again (withSource: true gives the complete call).', groups: [], relations: [], customRelations: [] };\n try {\n const __byPat = new Map();\n __rels.forEach((r) => {\n const pat = r.params && r.params.pattern;\n if (typeof pat === 'string') {\n const k = pat + '|' + r.type + '|' + r.to;\n if (!__byPat.has(k)) __byPat.set(k, { pattern: pat, type: r.type, to: r.to, members: [] });\n __byPat.get(k).members.push(r);\n return;\n }\n __edit.relations.push({ from: r.from, ...(r.fromName ? { fromName: r.f" +
|
|
12892
|
+
"romName } : {}), to: r.to, relationType: r.type,\n change: { type: 'relation', sourceId: r.fromName || r.from, ...(r.to != null ? { targetId: r.to } : {}), relationType: r.type, update: true, relationOptions: r.params } });\n });\n __byPat.forEach((g) => {\n const base = Object.assign({}, g.members[0].params); delete base.index; delete base.count; delete base.pattern;\n const names = g.members.map((m) => m.fromName).filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));\n const prefix = g.pattern.split('*')[0];\n const nums = names.map((n) => Number(n.slice(prefix.length))).filter((n) => Number.isFinite(n));\n const next = pr" +
|
|
12893
|
+
"efix + (nums.length ? Math.max.apply(null, nums) + 1 : g.members.length + 1);\n const firstEntry = app.itemRegistry.get(g.members[0].from);\n const bind = { type: 'relation', sourceId: g.pattern, ...(g.to != null ? { targetId: g.to } : {}), relationType: g.type, relationOptions: base };\n __edit.groups.push({ pattern: g.pattern, relationType: g.type, members: g.members.length, ...(names.length ? { first: names[0], last: names[names.length - 1] } : {}), relationOptions: base,\n change: Object.assign({}, bind, { update: true }),\n addMember: [{ type: 'create', itemType: (firstEntry && firstEntry.type) || 'circle', position: [0, 0], properties: { name: next } }, b" +
|
|
12894
|
+
"ind],\n ...(names.length ? { removeMember: [{ type: 'delete', itemId: names[names.length - 1] }, bind] } : {}) });\n });\n // The register call requires its source, so without withSource there is no call to offer: an op that can only be\n // refused is not one to send. reads is the server's own working-out from that source, not an argument of the call.\n __custom.forEach((d) => {\n const args = Object.assign({}, d); delete args.reads;\n __edit.customRelations.push(" +
|
|
12895
|
+
String(input.withSource === true ? "{ name: d.name, change: { tool: 'pinepaper_register_custom_relation', args } }" : "args") +
|
|
12896
|
+
");\n });\n } catch (e) { /* the read-back stands without the edit ops */ }\n if (!" +
|
|
12711
12897
|
String(input.withSource === true ? 'true' : 'false') +
|
|
12712
|
-
" && __custom.length) __edit.customHint = 'custom
|
|
12898
|
+
" && __custom.length) __edit.customHint = 'custom relations are listed without their source, and the call that redefines one needs it: pass withSource: true to get each complete pinepaper_register_custom_relation call.';\n // A GROUP IS LISTED ONCE (#43): members bound by a name pattern repeated the same relation once per item in\n // `relations`; they are in edit.groups, and counted here.\n const __loose = __rels.filter((r) => !(r.params && typeof r.params.pattern === 'string'));\n const __grouped = __rels.length - __loose.length;\n const __editOut = __edit.groups.length || __edit.relations.length || __edit.customRelations.length ? { edit: __edit } : {};\n if (" +
|
|
12713
12899
|
String(input.only === 'edit' ? 'true' : 'false') +
|
|
12714
12900
|
") {\n return Object.assign({ success: true }, __editOut.edit ? __editOut : { edit: { hint: 'nothing to change: this scene binds no relations.' } },\n __unusedCustom.length ? { registeredUnused: __unusedCustom } : {});\n }\n // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the\n // frame edge). Passed through when the studio reports them; an older studio has none to pass.\n return { success: true, density: result.density, suggestions: result.suggestions,\n ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}),\n relations: __loose, ...(__grouped ? { groupedRelations: __grouped" +
|
|
12715
12901
|
", groupedNote: 'relations bound by a name pattern are listed once per group in edit.groups' } : {}),\n ...(__relCount > __rels.length ? { relationsShown: __rels.length, relationCount: __relCount } : {}),\n ...(__custom.length ? { customRelations: __custom } : {}),\n ...(__unusedCustom.length ? { registeredUnused: __unusedCustom } : {}),\n ...__editOut };\n})();"
|
|
@@ -12775,6 +12961,7 @@ String(input.only === 'edit' ? 'true' : 'false') +
|
|
|
12775
12961
|
|
|
12776
12962
|
|
|
12777
12963
|
|
|
12964
|
+
|
|
12778
12965
|
|
|
12779
12966
|
|
|
12780
12967
|
).trim();
|
|
@@ -13324,7 +13511,7 @@ String(id) +
|
|
|
13324
13511
|
String(id) +
|
|
13325
13512
|
", beats: " +
|
|
13326
13513
|
String(input.beats.length) +
|
|
13327
|
-
", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };\n" +
|
|
13514
|
+
", keyframes: __r.keyframes.length, duration: __r.end, final: __r.final, ...(__r.defaultsUsed ? { defaultsUsed: __r.defaultsUsed } : {}) };\n" +
|
|
13328
13515
|
String(input.sound ? (" // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —\n // pop, launch, contact, shake — loud as the moment is strong. A sound that\n // does not place is reported; it never undoes the motion.\n const __map = Object.assign({ pop: 'pop', whoosh: 'whoosh', contact: 'tom', shake: 'hat' }, " +
|
|
13329
13516
|
String(S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})) +
|
|
13330
13517
|
");\n const __gain = " +
|
|
@@ -16033,6 +16220,8 @@ String(op) +
|
|
|
16033
16220
|
return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
|
|
16034
16221
|
})();`.trim();
|
|
16035
16222
|
}
|
|
16223
|
+
if (input.action !== 'pulse')
|
|
16224
|
+
return generateContraptionCode(input);
|
|
16036
16225
|
// pulse — a runtime fire, not a scene edit (no history save)
|
|
16037
16226
|
const eventIdJson = JSON.stringify(input.eventId);
|
|
16038
16227
|
const payloadJson = JSON.stringify(input.payload ?? null);
|
|
@@ -16047,6 +16236,74 @@ String(op) +
|
|
|
16047
16236
|
})();`.trim();
|
|
16048
16237
|
}
|
|
16049
16238
|
}
|
|
16239
|
+
/**
|
|
16240
|
+
* Rules, variables and bound text (the studio's contraption engine). Each action asks for the method it calls, so a
|
|
16241
|
+
* studio without it refuses by name instead of a TypeError. The engine's animateItem, playSound and custom actions are
|
|
16242
|
+
* not offered: animateItem goes through app.modify, which acts on the selection and not on its target; playSound does
|
|
16243
|
+
* not call app.playSound the way that method is written; custom needs a function, which JSON cannot carry. All three
|
|
16244
|
+
* answer ok regardless, so a rule using them would report success for nothing.
|
|
16245
|
+
*/
|
|
16246
|
+
function generateContraptionCode(input) {
|
|
16247
|
+
const refuse = (method) => `if (typeof app.${method} !== 'function') {
|
|
16248
|
+
return { success: false, action: ${jsLiteral(input.action)}, error: 'this studio has no contraption engine (app.${method}): update PinePaper Studio' };
|
|
16249
|
+
}`;
|
|
16250
|
+
const scope = jsLiteral(input.scope ?? 'default');
|
|
16251
|
+
const key = jsLiteral(input.key ?? '');
|
|
16252
|
+
const body = () => {
|
|
16253
|
+
switch (input.action) {
|
|
16254
|
+
case 'add_contraption': {
|
|
16255
|
+
const targets = [...new Set((input.actions ?? []).flatMap((a) => (a.type === 'modifyItem' ? [a.targetId] : [])))];
|
|
16256
|
+
const config = {
|
|
16257
|
+
...(input.contraptionId ? { id: input.contraptionId } : {}),
|
|
16258
|
+
sourceEvent: input.sourceEvent,
|
|
16259
|
+
actions: input.actions,
|
|
16260
|
+
...(input.condition ? { condition: input.condition } : {}),
|
|
16261
|
+
...(input.thenTrigger ? { thenTrigger: input.thenTrigger } : {}),
|
|
16262
|
+
...(input.elseTrigger ? { elseTrigger: input.elseTrigger } : {}),
|
|
16263
|
+
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
|
16264
|
+
};
|
|
16265
|
+
return `${refuse('addContraption')}
|
|
16266
|
+
const contraptionId = app.addContraption(${jsLiteral(config)});
|
|
16267
|
+
const missing = ${jsLiteral(targets)}.filter((id) => !app.getItemById(id));
|
|
16268
|
+
return { success: true, action: 'add_contraption', contraptionId,
|
|
16269
|
+
...(missing.length ? { warning: 'no item has the id ' + missing.join(', ') + ' yet: the rule does nothing to it until one does' } : {}),
|
|
16270
|
+
${targets.length ? "note: 'changes a rule makes to an item stay: nothing rewinds them, so another export starts from the changed scene'" : ''} };`;
|
|
16271
|
+
}
|
|
16272
|
+
case 'remove_contraption':
|
|
16273
|
+
return `${refuse('removeContraption')}
|
|
16274
|
+
const removed = app.removeContraption(${jsLiteral(input.contraptionId)});
|
|
16275
|
+
return removed ? { success: true, action: 'remove_contraption', contraptionId: ${jsLiteral(input.contraptionId)} }
|
|
16276
|
+
: { success: false, action: 'remove_contraption', error: 'no rule has the id ' + ${jsLiteral(input.contraptionId)} };`;
|
|
16277
|
+
case 'list_contraptions':
|
|
16278
|
+
return `${refuse('listContraptions')}
|
|
16279
|
+
return { success: true, action: 'list_contraptions', contraptions: app.listContraptions() };`;
|
|
16280
|
+
case 'set_variable':
|
|
16281
|
+
return `${refuse('setVariable')}
|
|
16282
|
+
app.setVariable(${key}, ${jsLiteral(input.value)}, ${scope});
|
|
16283
|
+
return { success: true, action: 'set_variable', key: ${key}, value: app.getVariable(${key}, null, ${scope}) };`;
|
|
16284
|
+
case 'get_variable':
|
|
16285
|
+
return `${refuse('getVariable')}
|
|
16286
|
+
return { success: true, action: 'get_variable', key: ${key}, value: app.getVariable(${key}, ${jsLiteral(input.fallback ?? null)}, ${scope}) };`;
|
|
16287
|
+
case 'increment_variable':
|
|
16288
|
+
return `${refuse('incrementVariable')}
|
|
16289
|
+
return { success: true, action: 'increment_variable', key: ${key}, value: app.incrementVariable(${key}, ${input.by ?? 1}, ${scope}) };`;
|
|
16290
|
+
default: {
|
|
16291
|
+
const itemId = jsLiteral(input.itemId);
|
|
16292
|
+
return `${refuse('bindTextToState')}
|
|
16293
|
+
const item = app.getItemById(${itemId});
|
|
16294
|
+
if (!item) return { success: false, action: 'bind_text', error: 'Item not found: ' + ${itemId} };
|
|
16295
|
+
if (item.content === undefined) return { success: false, action: 'bind_text', error: ${itemId} + ' is not a text item, so it has no text to bind' };
|
|
16296
|
+
app.bindTextToState(${itemId}, ${jsLiteral(input.template)}, ${scope});
|
|
16297
|
+
return { success: true, action: 'bind_text', itemId: ${itemId}, text: item.content };`;
|
|
16298
|
+
}
|
|
16299
|
+
}
|
|
16300
|
+
};
|
|
16301
|
+
return `
|
|
16302
|
+
// Contraption engine: ${input.action}
|
|
16303
|
+
(function() {
|
|
16304
|
+
${body()}
|
|
16305
|
+
})();`.trim();
|
|
16306
|
+
}
|
|
16050
16307
|
// Export singleton instance
|
|
16051
16308
|
export const codeGenerator = new PinePaperCodeGenerator();
|
|
16052
16309
|
/**
|