@pinepaper.studio/mcp-server 1.6.25 → 1.6.27

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.
Files changed (67) hide show
  1. package/README.md +108 -7
  2. package/SECURITY.md +15 -1
  3. package/dist/agent/types.d.ts +3 -3
  4. package/dist/browser/puppeteer-controller.d.ts +12 -1
  5. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  6. package/dist/browser/puppeteer-controller.js +105 -15
  7. package/dist/browser/puppeteer-controller.js.map +1 -1
  8. package/dist/build-info.json +2 -2
  9. package/dist/ontology/hints.d.ts +3 -1
  10. package/dist/ontology/hints.d.ts.map +1 -1
  11. package/dist/ontology/hints.js +4 -2
  12. package/dist/ontology/hints.js.map +1 -1
  13. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  14. package/dist/ontology/upstream-vocabulary.js +1 -1
  15. package/dist/ontology/vocabulary.d.ts.map +1 -1
  16. package/dist/ontology/vocabulary.js +2 -0
  17. package/dist/ontology/vocabulary.js.map +1 -1
  18. package/dist/tools/definitions.d.ts.map +1 -1
  19. package/dist/tools/definitions.js +23 -10
  20. package/dist/tools/definitions.js.map +1 -1
  21. package/dist/tools/engine-lists.d.ts +11 -1
  22. package/dist/tools/engine-lists.d.ts.map +1 -1
  23. package/dist/tools/engine-lists.js +109 -1
  24. package/dist/tools/engine-lists.js.map +1 -1
  25. package/dist/tools/engine-methods.d.ts.map +1 -1
  26. package/dist/tools/engine-methods.js +1 -0
  27. package/dist/tools/engine-methods.js.map +1 -1
  28. package/dist/tools/engine-surface.d.ts +1 -1
  29. package/dist/tools/engine-surface.d.ts.map +1 -1
  30. package/dist/tools/engine-surface.js +7 -4
  31. package/dist/tools/engine-surface.js.map +1 -1
  32. package/dist/tools/export-file-receipt.d.ts +43 -0
  33. package/dist/tools/export-file-receipt.d.ts.map +1 -1
  34. package/dist/tools/export-file-receipt.js +65 -3
  35. package/dist/tools/export-file-receipt.js.map +1 -1
  36. package/dist/tools/expression-grammar.d.ts +10 -0
  37. package/dist/tools/expression-grammar.d.ts.map +1 -0
  38. package/dist/tools/expression-grammar.js +33 -0
  39. package/dist/tools/expression-grammar.js.map +1 -0
  40. package/dist/tools/handlers/export.d.ts.map +1 -1
  41. package/dist/tools/handlers/export.js +54 -4
  42. package/dist/tools/handlers/export.js.map +1 -1
  43. package/dist/tools/handlers.d.ts.map +1 -1
  44. package/dist/tools/handlers.js +61 -2
  45. package/dist/tools/handlers.js.map +1 -1
  46. package/dist/tools/lane.d.ts +4 -3
  47. package/dist/tools/lane.d.ts.map +1 -1
  48. package/dist/tools/lane.js +31 -5
  49. package/dist/tools/lane.js.map +1 -1
  50. package/dist/tools/scene-cache-export.d.ts +52 -0
  51. package/dist/tools/scene-cache-export.d.ts.map +1 -0
  52. package/dist/tools/scene-cache-export.js +227 -0
  53. package/dist/tools/scene-cache-export.js.map +1 -0
  54. package/dist/tools/shape-params.d.ts +2 -2
  55. package/dist/tools/shape-params.js +2 -2
  56. package/dist/tools/stick-vocabulary.d.ts +1 -1
  57. package/dist/tools/stick-vocabulary.js +1 -1
  58. package/dist/types/code-generator.d.ts +1 -0
  59. package/dist/types/code-generator.d.ts.map +1 -1
  60. package/dist/types/code-generator.js +459 -249
  61. package/dist/types/code-generator.js.map +1 -1
  62. package/dist/types/schemas.d.ts +211 -199
  63. package/dist/types/schemas.d.ts.map +1 -1
  64. package/dist/types/schemas.js +7 -1
  65. package/dist/types/schemas.js.map +1 -1
  66. package/manifest.json +1 -1
  67. package/package.json +1 -9
@@ -7,11 +7,18 @@
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 } from '../tools/engine-lists.js';
10
+ import { WORLD3D_COLOR_PATHS, PATTERN_KINDS, EXPRESSION_FUNCTION_ARITY, EXPRESSION_CONSTANTS, EXPRESSION_REJECTED, RELATION_PARAM_KEYS } from '../tools/engine-lists.js';
11
+ import { expressionGrammarText } from '../tools/expression-grammar.js';
11
12
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
12
13
  import { NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
13
14
  import { CREATE_KNOWN_KEYS } from '../tools/create-known-keys.js';
14
15
  import { jsLiteral } from '../utils/js-literal.js';
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
+ /** How many relations pinepaper_lint_scene lists back; past it the result says how many there are. */
18
+ const RELATIONS_READBACK_CAP = 200;
19
+ function oneLine(v) {
20
+ return String(v).replace(/[\r\n\u2028\u2029]+/g, ' ');
21
+ }
15
22
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
16
23
  import { expandBatchMacros } from '../tools/batch-macros.js';
17
24
  import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
@@ -78,7 +85,7 @@ function normalizeGradientSpec(v, key) {
78
85
  */
79
86
  function generateGradientCode(gradient, itemVarName, position, propertyName = 'fillColor') {
80
87
  const { type, stops, origin, destination } = gradient;
81
- const stopsCode = stops.map(s => `['${s.color}', ${s.offset}]`).join(', ');
88
+ const stopsCode = stops.map(s => `[${jsLiteral(s.color)}, ${s.offset}]`).join(', ');
82
89
  if (type === 'radial') {
83
90
  // A RADIAL CORE BELONGS ON THE ITEM, NOT ON THE NUMBER ASKED FOR (17
84
91
  // RADIALOFFSET). The origin was the REQUESTED position and the radius a
@@ -126,7 +133,7 @@ ${itemVarName}.${propertyName} = {
126
133
  function generateShadowCode(itemVarName, shadowColor, shadowBlur, shadowOffset) {
127
134
  const lines = [];
128
135
  if (shadowColor) {
129
- lines.push(`${itemVarName}.shadowColor = '${shadowColor}';`);
136
+ lines.push(`${itemVarName}.shadowColor = ${jsLiteral(shadowColor)};`);
130
137
  }
131
138
  if (shadowBlur !== undefined) {
132
139
  lines.push(`${itemVarName}.shadowBlur = ${shadowBlur};`);
@@ -140,7 +147,7 @@ function generateShadowCode(itemVarName, shadowColor, shadowBlur, shadowOffset)
140
147
  * Generate blend mode code for an item
141
148
  */
142
149
  function generateBlendModeCode(itemVarName, blendMode) {
143
- return `${itemVarName}.blendMode = '${blendMode}';`;
150
+ return `${itemVarName}.blendMode = ${jsLiteral(blendMode)};`;
144
151
  }
145
152
  // =============================================================================
146
153
  // CODE TEMPLATES
@@ -155,7 +162,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
155
162
  // break those later calls: refused here, by name, before anything is made.
156
163
  if (typeof properties.id === 'string' && UNSAFE_ID_CHARS.test(properties.id)) {
157
164
  return `
158
- // Create ${itemType} — refused: an id later calls could not address
165
+ // Create ${oneLine(itemType)} — refused: an id later calls could not address
159
166
  ({ success: false, error: ${jsLiteral(`properties.id ${JSON.stringify(properties.id)} has a quote, backslash or line break, so later calls could not address the item. Nothing was made. Use letters, digits, spaces, - and _ (e.g. "bobs-hat").`)} });`.trim();
160
167
  }
161
168
  // Colour objects are gradient specs: normalised, or refused by name.
@@ -165,7 +172,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
165
172
  const g = normalizeGradientSpec(v, key);
166
173
  if ('error' in g) {
167
174
  return `
168
- // Create ${itemType} — refused: an unreadable gradient would draw nothing
175
+ // Create ${oneLine(itemType)} — refused: an unreadable gradient would draw nothing
169
176
  ({ success: false, error: ${JSON.stringify(g.error)} });`.trim();
170
177
  }
171
178
  properties = { ...properties, [key]: g.gradient };
@@ -274,7 +281,7 @@ function generateCreateItemCode(itemType, position, properties, dataFlags, posit
274
281
  if (params.opacity != null)
275
282
  plate.opacity = params.opacity;
276
283
  return `
277
- // ${itemType} — a render-time surface: a plate tagged with what to draw over it.
284
+ // ${oneLine(itemType)} — a render-time surface: a plate tagged with what to draw over it.
278
285
  const item = app.create('rectangle', ${JSON.stringify(plate, null, 2)});
279
286
  if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.data.renderParams = ${JSON.stringify(params)}; }
280
287
  // The snippet's value is its last statement's, which was the assignment above:
@@ -284,7 +291,7 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
284
291
  // and its value is lost.
285
292
  ((item && item.data)
286
293
  ? { itemId: item.data.registryId, type: ${JSON.stringify(itemType)}, position: { x: ${position.x}, y: ${position.y} } }
287
- : { success: false, error: 'the plate for this ${itemType} was not created.' });`;
294
+ : { success: false, error: 'the plate for this ' + ${jsLiteral(itemType)} + ' was not created.' });`;
288
295
  }
289
296
  // AN UNKNOWN ANCHOR IS INVISIBLE OVER MCP.
290
297
  //
@@ -299,7 +306,7 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
299
306
  const normalized = String(anchorGiven).toLowerCase().replace(/[\s_]+/g, '-');
300
307
  if (!ANCHORS.includes(normalized)) {
301
308
  return `
302
- // Create ${itemType} — refused, rather than silently centred
309
+ // Create ${oneLine(itemType)} — refused, rather than silently centred
303
310
  ({ success: false, error: ${JSON.stringify(`anchor ${JSON.stringify(String(anchorGiven))} is not one of ${ANCHORS.slice(0, 5).join(', ')}. `
304
311
  + 'The engine would leave x/y as the CENTRE and say so only in a console this tool cannot read, '
305
312
  + 'so the item would land half its own size away with nothing to explain it.')} });`.trim();
@@ -331,8 +338,8 @@ if (item && item.data) { item.data.renderAs = ${JSON.stringify(itemType)}; item.
331
338
  : !CREATE_KNOWN_KEYS.has(k) || textOnly(k));
332
339
  // Build the code
333
340
  let code = `
334
- // Create ${itemType} item
335
- const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
341
+ // Create ${oneLine(itemType)} item
342
+ const item = app.create(${jsLiteral(itemType)}, ${JSON.stringify(params, null, 2)});`;
336
343
  // Add gradient support for colors
337
344
  if (color && isGradient(color)) {
338
345
  code += generateGradientCode(color, 'item', position, 'fillColor');
@@ -413,7 +420,7 @@ const item = app.create('${itemType}', ${JSON.stringify(params, null, 2)});`;
413
420
  opts.countdownTarget = countdownTarget;
414
421
  if (countdownEndText)
415
422
  opts.countdownEndText = countdownEndText;
416
- code += `\nif (app.setDynamicContent) app.setDynamicContent(item, '${contentType}', ${JSON.stringify(opts)});`;
423
+ code += `\nif (app.setDynamicContent) app.setDynamicContent(item, ${jsLiteral(contentType)}, ${JSON.stringify(opts)});`;
417
424
  }
418
425
  const loadsFont = itemType === 'text' && typeof properties.fontFamily === 'string' && properties.fontFamily.trim() !== '';
419
426
  // A LINE WITH `from` IS DRAWN WHERE from AND to SAY (issue #29 O8). They are
@@ -435,7 +442,7 @@ const itemId = item.data.registryId;
435
442
  app.historyManager.saveState();
436
443
 
437
444
  // Return item info
438
- ({ itemId, type: '${itemType}'${outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : ''}${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
445
+ ({ itemId, type: ${jsLiteral(itemType)}${outlineOnly ? `, fill: ${JSON.stringify(OUTLINE_NOTE)}` : ''}${textFit ? ', textFit: __textFit' : ''}${textDirection ? ', direction: __direction' : ''}${textTabular ? ', tabularFigures: __tabular' : ''}${createFillOpacity ? ', fillOpacity: __fillOpacity' : ''}${textStrokePos ? ', strokePosition: __strokePos' : ''}, position: ${reportActualPosition ? `(item.position ? { x: item.position.x, y: item.position.y } : null)` : `{ x: ${position.x}, y: ${position.y} }`}${hasLifetime ? ', lifetime: __lifetime' : ''}${loadsFont ? ', ...(__font ? { font: __font } : {})' : ''}${ignored.length > 0
439
446
  ? `, ignoredProperties: ${JSON.stringify(ignored)}` : ''}${warnings.length > 0 ? `, warning: ${JSON.stringify(warnings.join(' '))}` : ''} });`;
440
447
  // Loading a font is async, and it has to happen BEFORE create() measures the
441
448
  // text — so only this case becomes an async IIFE; every other create stays a
@@ -1003,7 +1010,7 @@ if (entry && entry.item && app.removeDynamicContent) app.removeDynamicContent(en
1003
1010
  opts.countdownEndText = countdownEndText;
1004
1011
  code += `
1005
1012
  const entry = app.itemRegistry.get(${ID});
1006
- if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, '${contentType}', ${jsLiteral(opts)});`;
1013
+ if (entry && entry.item && app.setDynamicContent) app.setDynamicContent(entry.item, ${jsLiteral(contentType)}, ${jsLiteral(opts)});`;
1007
1014
  }
1008
1015
  }
1009
1016
  // AN AUDIO ITEM'S LEVEL IS NOT AN ITEM PROPERTY. modifyItem applies changes
@@ -1221,6 +1228,8 @@ export function relationSpecProblem(relationType, params) {
1221
1228
  const p = (params && typeof params === 'object' ? params : {});
1222
1229
  if (relationType === 'on_event_fire_if')
1223
1230
  return fireIfProblem(p);
1231
+ if (relationType === 'time_expression' && typeof p.expression === 'string')
1232
+ return expressionProblem(p.expression);
1224
1233
  if (typeof relationType === 'string' && VISIBILITY_REACTION.test(relationType))
1225
1234
  return visibilityProblem(relationType, p);
1226
1235
  if (relationType === 'on_event_add_relation' && (p.type === 'on_event_fire_if' || (typeof p.type === 'string' && VISIBILITY_REACTION.test(p.type)))) {
@@ -1230,6 +1239,110 @@ export function relationSpecProblem(relationType, params) {
1230
1239
  }
1231
1240
  return null;
1232
1241
  }
1242
+ /**
1243
+ * A time_expression formula outside the engine's grammar parses to nothing, and the relation then does nothing with
1244
+ * success reported (#39: Math.sin(...) from JavaScript habit, no motion). Names are checked against the grammar the
1245
+ * engine publishes (engine-lists, generated from expr-to-ir.js); syntax is the engine's to judge.
1246
+ */
1247
+ function expressionProblem(expr) {
1248
+ const allowed = new Set([...Object.keys(EXPRESSION_FUNCTION_ARITY), ...EXPRESSION_CONSTANTS, 't', 'v']);
1249
+ const grammar = expressionGrammarText();
1250
+ for (let i = 0; i < expr.length;) {
1251
+ const c = expr[i];
1252
+ if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(expr[i + 1] ?? ''))) {
1253
+ i++;
1254
+ while (i < expr.length && /[0-9.]/.test(expr[i]))
1255
+ i++;
1256
+ if (/[eE]/.test(expr[i] ?? '') && /[0-9+-]/.test(expr[i + 1] ?? '')) {
1257
+ i += 2;
1258
+ while (i < expr.length && /[0-9]/.test(expr[i]))
1259
+ i++;
1260
+ }
1261
+ continue;
1262
+ }
1263
+ if (/[A-Za-z_]/.test(c)) {
1264
+ let j = i + 1;
1265
+ while (j < expr.length && /[A-Za-z0-9_]/.test(expr[j]))
1266
+ j++;
1267
+ const name = expr.slice(i, j);
1268
+ if (expr[j] === '.') {
1269
+ const member = /^[A-Za-z_][A-Za-z0-9_]*/.exec(expr.slice(j + 1))?.[0] ?? '';
1270
+ return `expression ${JSON.stringify(expr)} uses ${name}.${member}, and the formula is not JavaScript: it would parse to nothing and the item would not move. Write ${member || 'the function'}(…) without ${name}. The grammar: ${grammar}.`;
1271
+ }
1272
+ if (EXPRESSION_REJECTED.includes(name)) {
1273
+ return `expression ${JSON.stringify(expr)} uses ${name}(), which the engine refuses: a formula must give the same value at the same time in every export. Use noise(t) for a varying value that repeats.`;
1274
+ }
1275
+ const arity = EXPRESSION_FUNCTION_ARITY[name];
1276
+ if (arity !== undefined) {
1277
+ // Count the call's top-level arguments: a wrong count parses to nothing as well (min(1, 2, 3)).
1278
+ let k = j;
1279
+ while (expr[k] === ' ')
1280
+ k++;
1281
+ if (expr[k] === '(') {
1282
+ let depth = 0, args = 0, any = false;
1283
+ for (let q = k; q < expr.length; q++) {
1284
+ const ch = expr[q];
1285
+ if (ch === '(') {
1286
+ depth++;
1287
+ continue;
1288
+ }
1289
+ if (ch === ')') {
1290
+ depth--;
1291
+ if (depth === 0)
1292
+ break;
1293
+ continue;
1294
+ }
1295
+ if (depth === 1 && ch === ',')
1296
+ args++;
1297
+ if (depth >= 1 && ch.trim())
1298
+ any = true;
1299
+ }
1300
+ const given = any ? args + 1 : 0;
1301
+ if (given !== arity)
1302
+ return `expression ${JSON.stringify(expr)} calls ${name} with ${given} argument${given === 1 ? '' : 's'}; it takes ${arity}, and any other count parses to nothing, so the item would not move.`;
1303
+ }
1304
+ }
1305
+ if (!allowed.has(name)) {
1306
+ const near = [...allowed].find((a) => a.toLowerCase() === name.toLowerCase());
1307
+ return `expression ${JSON.stringify(expr)} names ${name}, which the formula grammar does not have, so it would parse to nothing and the item would not move.${near ? ` Did you mean ${near}?` : ''} The grammar: ${grammar}.`;
1308
+ }
1309
+ i = j;
1310
+ continue;
1311
+ }
1312
+ i++;
1313
+ }
1314
+ return null;
1315
+ }
1316
+ /**
1317
+ * A relationOptions key the rule never reads, said instead of accepted in silence. The list is the engine's own
1318
+ * (RELATION_PARAM_KEYS: what each rule declares or reads); a rule without one (custom, event reactions) gets no
1319
+ * warning. `injected` are keys the tool added itself (a name pattern's index and count).
1320
+ */
1321
+ /** Levenshtein distance, for a "did you mean" on a misspelt key. */
1322
+ function editDistance(a, b) {
1323
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
1324
+ for (let i = 1; i <= a.length; i++) {
1325
+ let prev = row[0];
1326
+ row[0] = i;
1327
+ for (let j = 1; j <= b.length; j++) {
1328
+ const t = row[j];
1329
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
1330
+ prev = t;
1331
+ }
1332
+ }
1333
+ return row[b.length];
1334
+ }
1335
+ export function relationOptionWarning(relationType, params, injected = []) {
1336
+ const keys = typeof relationType === 'string' ? RELATION_PARAM_KEYS[relationType] : undefined;
1337
+ if (!keys || !params || typeof params !== 'object')
1338
+ return null;
1339
+ const unknown = Object.keys(params).filter((k) => !keys.includes(k) && !injected.includes(k));
1340
+ if (!unknown.length)
1341
+ return null;
1342
+ const norm = (x) => x.replace(/_/g, '').toLowerCase();
1343
+ 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);
1344
+ return `${relationType} does not read ${unknown.join(', ')}, so ${unknown.length > 1 ? 'they were' : 'it was'} ignored${hint.length ? ` (${hint.join('; ')})` : ''}. ${relationType} reads: ${keys.join(', ')}.`;
1345
+ }
1233
1346
  const VISIBILITY_REACTION = /^on_(event|enter|exit)_set_visibility$/;
1234
1347
  /**
1235
1348
  * A visibility reaction reads params.visible and nothing else: absent means show. `value`, which the other set_*
@@ -1249,23 +1362,23 @@ function generateAddRelationCode(sourceId, targetId, relationType, params) {
1249
1362
  // an item, the camera, a cursor target — is asked first here, after the same
1250
1363
  // '#id' forgiveness the engine applies.
1251
1364
  return `
1252
- // Add ${relationType} relation: ${sourceId} -> ${targetId}
1365
+ // Add ${oneLine(relationType)} relation: ${oneLine(sourceId)} -> ${oneLine(targetId)}
1253
1366
  (function() {
1254
1367
  const norm = function(id) { return typeof app._resolveRelationEndpoint === 'function' ? app._resolveRelationEndpoint(id) : id; };
1255
1368
  if (typeof app._isKnownRelationEndpoint === 'function') {
1256
1369
  const bad = [${JSON.stringify(sourceId)}, ${JSON.stringify(targetId)}].filter(function(id) { return !app._isKnownRelationEndpoint(norm(id)); });
1257
1370
  if (bad.length) {
1258
- return { success: false, relationType: '${relationType}', error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')
1371
+ return { success: false, relationType: ${jsLiteral(relationType)}, error: bad.map(function(id) { return JSON.stringify(id); }).join(' and ')
1259
1372
  + ' ' + (bad.length > 1 ? 'are not items' : 'is not an item') + ' on the canvas, so the relation would be stored and skipped every frame. Check the id (pinepaper_get_items lists them).' };
1260
1373
  }
1261
1374
  }
1262
- const success = app.addRelation('${sourceId}', '${targetId}', '${relationType}', ${JSON.stringify(params, null, 2)});
1375
+ const success = app.addRelation(${jsLiteral(sourceId)}, ${jsLiteral(targetId)}, ${jsLiteral(relationType)}, ${JSON.stringify(params, null, 2)});
1263
1376
  if (!success) {
1264
1377
  const __why = ${relationRefusalReasonJs(jsLiteral(relationType))};
1265
- return { success: false, relationType: '${relationType}', error: 'the studio refused the ${relationType} relation between ${sourceId} and ${targetId}' + (__why ? ': ' + __why : '.') };
1378
+ return { success: false, relationType: ${jsLiteral(relationType)}, error: 'the studio refused the ' + ${jsLiteral(relationType)} + ' relation between ' + ${jsLiteral(sourceId)} + ' and ' + ${jsLiteral(targetId)} + (__why ? ': ' + __why : '.') };
1266
1379
  }
1267
1380
  app.historyManager.saveState();
1268
- return { success: true, sourceId: '${sourceId}', targetId: '${targetId}', relationType: '${relationType}' };
1381
+ return { success: true, sourceId: ${jsLiteral(sourceId)}, targetId: ${jsLiteral(targetId)}, relationType: ${jsLiteral(relationType)}${(() => { const w = relationOptionWarning(relationType, params); return w ? `, warning: ${jsLiteral(w)}` : ''; })()} };
1269
1382
  })();
1270
1383
  `.trim();
1271
1384
  }
@@ -1423,7 +1536,7 @@ function generateMovesAlongPathCode(sourceId, targetId, raw) {
1423
1536
  const hasDuration = typeof duration === 'number' && duration > 0;
1424
1537
  const hasDelay = typeof delay === 'number' && delay > 0;
1425
1538
  return `
1426
- // moves_along_path: ${sourceId}${ref ? ` along ${ref}` : ''}
1539
+ // moves_along_path: ${oneLine(sourceId)}${ref ? ` along ${oneLine(ref)}` : ''}
1427
1540
  (function() {
1428
1541
  const params = ${JSON.stringify(params)};
1429
1542
  const hasPoints = Array.isArray(params.path) && params.path.length >= 2;
@@ -1537,14 +1650,14 @@ function aliasGeneratorParams(name, params) {
1537
1650
  * Template for removing relations
1538
1651
  */
1539
1652
  function generateRemoveRelationCode(sourceId, targetId, relationType) {
1540
- const relationArg = relationType ? `'${relationType}'` : 'undefined';
1653
+ const relationArg = relationType ? jsLiteral(relationType) : 'undefined';
1541
1654
  return `
1542
- // Remove relation: ${sourceId} -> ${targetId}${relationType ? ` (${relationType})` : ''}
1543
- app.removeRelation('${sourceId}', '${targetId}', ${relationArg});
1655
+ // Remove relation: ${oneLine(sourceId)} -> ${oneLine(targetId)}${relationType ? ` (${oneLine(relationType)})` : ''}
1656
+ app.removeRelation(${jsLiteral(sourceId)}, ${jsLiteral(targetId)}, ${relationArg});
1544
1657
  app.historyManager.saveState();
1545
1658
 
1546
1659
  // Return success
1547
- ({ success: true, sourceId: '${sourceId}', targetId: '${targetId}' });
1660
+ ({ success: true, sourceId: ${jsLiteral(sourceId)}, targetId: ${jsLiteral(targetId)} });
1548
1661
  `.trim();
1549
1662
  }
1550
1663
  /**
@@ -1553,7 +1666,7 @@ app.historyManager.saveState();
1553
1666
  function generateQueryRelationsCode(itemId, relationType, direction = 'outgoing') {
1554
1667
  const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1555
1668
  if (direction === 'outgoing') {
1556
- const typeArg = relationType ? `'${relationType}'` : 'undefined';
1669
+ const typeArg = relationType ? jsLiteral(relationType) : 'undefined';
1557
1670
  return `
1558
1671
  // Query outgoing relations for ${ID}
1559
1672
  const relations = app.getRelations(${ID}, ${typeArg});
@@ -1574,7 +1687,7 @@ const formatted = relations.map(r => ({
1574
1687
  `.trim();
1575
1688
  }
1576
1689
  else {
1577
- const typeArg = relationType ? `'${relationType}'` : 'undefined';
1690
+ const typeArg = relationType ? jsLiteral(relationType) : 'undefined';
1578
1691
  return `
1579
1692
  // Query incoming relations for ${ID}
1580
1693
  const results = app.queryByRelationTarget(${ID}, ${typeArg});
@@ -1586,7 +1699,7 @@ const relations = results.map(r => ({
1586
1699
  // queryByTarget returns the real relation name on each result. Hardcoding
1587
1700
  // the filter string labelled every edge 'unknown' on an unfiltered query —
1588
1701
  // which is exactly the query you make when exploring an unfamiliar graph.
1589
- relationType: r.relation || ${relationType ? `'${relationType}'` : "'unknown'"},
1702
+ relationType: r.relation || ${relationType ? jsLiteral(relationType) : "'unknown'"},
1590
1703
  params: r.params || {}
1591
1704
  }));
1592
1705
 
@@ -1602,13 +1715,13 @@ function generateAnimateCode(itemId, animationType, speed, intensity, delay) {
1602
1715
  const extra = (intensity !== undefined ? `,\n animationIntensity: ${intensity}` : '') +
1603
1716
  (delay !== undefined ? `,\n animationDelay: ${delay}` : '');
1604
1717
  return `
1605
- // Apply ${animationType} animation to ${ID}
1718
+ // Apply ${oneLine(animationType)} animation to ${ID}
1606
1719
  const item = app.getItemById(${ID});
1607
1720
  if (!item) {
1608
1721
  throw new Error('Item not found: ' + ${ID});
1609
1722
  }
1610
1723
  app.animate(item, {
1611
- animationType: '${animationType}',
1724
+ animationType: ${jsLiteral(animationType)},
1612
1725
  animationSpeed: ${speed}${extra}
1613
1726
  });
1614
1727
 
@@ -1627,7 +1740,7 @@ app.animate(item, {
1627
1740
  known: r.known,
1628
1741
  };
1629
1742
  }
1630
- return { success: true, itemId: ${ID}, animationType: '${animationType}' };
1743
+ return { success: true, itemId: ${ID}, animationType: ${jsLiteral(animationType)} };
1631
1744
  })();
1632
1745
  `.trim();
1633
1746
  }
@@ -1868,7 +1981,7 @@ function generateExecuteGeneratorCode(generatorName, params, region) {
1868
1981
  ? ` in region ${region.width}×${region.height} @ (${region.x},${region.y})`
1869
1982
  : ` in the shape of ${region.shape.itemId}`;
1870
1983
  return `
1871
- // Execute ${generatorName} generator${where}
1984
+ // Execute ${oneLine(generatorName)} generator${oneLine(where)}
1872
1985
  (async function() {${emitGeneratorRun(generatorName, JSON.stringify(params, null, 2), region)}
1873
1986
  })();
1874
1987
  `.trim();
@@ -1895,9 +2008,9 @@ function emitGeneratorRun(generatorName, paramsJson, region) {
1895
2008
  if (!__si) return { success: false, error: ${JSON.stringify(`region.shape names no item "${itemId}" — nothing was drawn. List items with pinepaper_get_items.`)} };
1896
2009
  if (__si.className !== 'Path' && __si.className !== 'CompoundPath') return { success: false, error: ${JSON.stringify(`region.shape item "${itemId}" is a `)} + __si.className + ', and a clip needs a path (a shape, not text, a group or an image) — nothing was drawn.' };
1897
2010
  }` : ''}
1898
- await app.executeGenerator('${generatorName}', ${paramsJson}, ${options});${shaped ? `
2011
+ await app.executeGenerator(${jsLiteral(generatorName)}, ${paramsJson}, ${options});${shaped ? `
1899
2012
  const __shapes = !!(app.generatorRegistry && typeof app.generatorRegistry._regionClipPath === 'function');` : ''}
1900
- return { success: true, generator: '${generatorName}'${region ? `, region: ${JSON.stringify(region)}` : ''}${shaped ? `, ...(__shapes ? {} : { regionShapeApplied: false, warning: ${JSON.stringify(`this studio does not clip generator regions to a shape, so ${typeof region.shape === 'object' ? 'the region had no box and the generator drew across the whole canvas' : `the ${region.shape} was drawn as a rectangle`}. It needs an engine with region shapes.`)} })` : ''} };`;
2013
+ return { success: true, generator: ${jsLiteral(generatorName)}${region ? `, region: ${JSON.stringify(region)}` : ''}${shaped ? `, ...(__shapes ? {} : { regionShapeApplied: false, warning: ${JSON.stringify(`this studio does not clip generator regions to a shape, so ${typeof region.shape === 'object' ? 'the region had no box and the generator drew across the whole canvas' : `the ${region.shape} was drawn as a rectangle`}. It needs an engine with region shapes.`)} })` : ''} };`;
1901
2014
  }
1902
2015
  /**
1903
2016
  * Template for applying effects
@@ -1905,14 +2018,14 @@ function emitGeneratorRun(generatorName, paramsJson, region) {
1905
2018
  function generateApplyEffectCode(itemId, effectType, params) {
1906
2019
  const ID = jsLiteral(itemId); // caller text: a quoted literal, never spliced raw
1907
2020
  return `
1908
- // Apply ${effectType} effect to ${ID}
2021
+ // Apply ${oneLine(effectType)} effect to ${ID}
1909
2022
  const item = app.getItemById(${ID});
1910
2023
  if (!item) {
1911
2024
  throw new Error('Item not found: ' + ${ID});
1912
2025
  }
1913
- app.applyEffect(item, '${effectType}', ${JSON.stringify(params, null, 2)});
2026
+ app.applyEffect(item, ${jsLiteral(effectType)}, ${JSON.stringify(params, null, 2)});
1914
2027
 
1915
- ({ success: true, itemId: ${ID}, effectType: '${effectType}' });
2028
+ ({ success: true, itemId: ${ID}, effectType: ${jsLiteral(effectType)} });
1916
2029
  `.trim();
1917
2030
  }
1918
2031
  /**
@@ -1964,9 +2077,9 @@ function generateGetItemsCode(filter) {
1964
2077
  // conditions, not on the keys.
1965
2078
  const conditions = [];
1966
2079
  if (filter?.type)
1967
- conditions.push(`entry.type === '${filter.type}'`);
2080
+ conditions.push(`entry.type === ${jsLiteral(filter.type)}`);
1968
2081
  if (filter?.source)
1969
- conditions.push(`entry.source === '${filter.source}'`);
2082
+ conditions.push(`entry.source === ${jsLiteral(filter.source)}`);
1970
2083
  if (filter?.hasAnimation !== undefined) {
1971
2084
  // 'none' is what the engine stores on a still item, and !!'none' is true
1972
2085
  // (gate H5): animated means a real loop animation OR keyframes.
@@ -2163,8 +2276,8 @@ app.setPlaybackTime(${time || 0});
2163
2276
  function generateSetBackgroundColorCode(color) {
2164
2277
  return `
2165
2278
  // Set background color
2166
- app.setBackgroundColor('${color}');
2167
- ({ success: true, color: '${color}' });
2279
+ app.setBackgroundColor(${jsLiteral(color)});
2280
+ ({ success: true, color: ${jsLiteral(color)} });
2168
2281
  `.trim();
2169
2282
  }
2170
2283
  /**
@@ -2671,7 +2784,7 @@ ${body}
2671
2784
  } catch (e) { errors.push({ index: ${i}, error: (e && e.message) || String(e) }); }`;
2672
2785
  });
2673
2786
  return `
2674
- // Batch create ${items.length} items — create_item for each, one history save
2787
+ // Batch create ${oneLine(items.length)} items — create_item for each, one history save
2675
2788
  (async function() {
2676
2789
  const results = [];
2677
2790
  const errors = [];
@@ -2704,7 +2817,7 @@ function generateBatchModifyCode(modifications) {
2704
2817
  } catch (e) { skipped.push({ itemId: ${JSON.stringify(m.itemId)}, reason: (e && e.message) || String(e) }); }`;
2705
2818
  });
2706
2819
  return `
2707
- // Batch modify ${modifications.length} items — modify_item for each, one history save
2820
+ // Batch modify ${oneLine(modifications.length)} items — modify_item for each, one history save
2708
2821
  (async function() {
2709
2822
  let count = 0;
2710
2823
  const skipped = [];
@@ -2726,11 +2839,11 @@ ${blocks.join('\n')}
2726
2839
  */
2727
2840
  function generateCreateGridCode(cols, rows, strokeColor, strokeWidth, animated, waveSpeed, waveAmplitude) {
2728
2841
  return `
2729
- // Create grid with ${cols} columns and ${rows} rows
2842
+ // Create grid with ${oneLine(cols)} columns and ${oneLine(rows)} rows
2730
2843
  const grid = app.createGrid({
2731
2844
  cols: ${cols},
2732
2845
  rows: ${rows},
2733
- strokeColor: '${strokeColor}',
2846
+ strokeColor: ${jsLiteral(strokeColor)},
2734
2847
  strokeWidth: ${strokeWidth},
2735
2848
  animated: ${animated},
2736
2849
  waveSpeed: ${waveSpeed},
@@ -3119,7 +3232,7 @@ export class PinePaperCodeGenerator {
3119
3232
  // than read as a single string, which would render "[object Object]".
3120
3233
  if (validated.presetId) {
3121
3234
  return `
3122
- // Adopt relation preset "${validated.presetId}" on ${validated.sourceId}
3235
+ // Adopt relation preset ${jsLiteral(validated.presetId)} on ${jsLiteral(validated.sourceId)}
3123
3236
  (async function() {
3124
3237
  if (typeof app.applyRelationPreset !== 'function') { return { success: false, error: 'app.applyRelationPreset unavailable — update FxTool' }; }
3125
3238
  const r = await app.applyRelationPreset(${JSON.stringify(validated.sourceId)}, ${JSON.stringify(validated.presetId)}, ${JSON.stringify(validated.presetValues ?? {})});
@@ -3149,7 +3262,7 @@ export class PinePaperCodeGenerator {
3149
3262
  if (validated.targetId)
3150
3263
  return code;
3151
3264
  return code
3152
- .replace(`return { success: true, sourceId: '${validated.sourceId}', targetId: '${validated.sourceId}',`, `return { success: true, sourceId: '${validated.sourceId}', targetId: null,`)
3265
+ .replace(`return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: ${jsLiteral(validated.sourceId)},`, `return { success: true, sourceId: ${jsLiteral(validated.sourceId)}, targetId: null,`)
3153
3266
  .replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
3154
3267
  }
3155
3268
  /**
@@ -3313,7 +3426,7 @@ ${asReturningBody(code)}
3313
3426
  : `{ item: app.importSVG(${varName}), changes: [] }`;
3314
3427
  if (url) {
3315
3428
  return `
3316
- // Import SVG from URL${source === 'figma' ? ' (Figma-normalised)' : ''}
3429
+ // Import SVG from URL${oneLine(source === 'figma' ? ' (Figma-normalised)' : '')}
3317
3430
  // WRAPPED, because these were bare top-level statements with an await. That
3318
3431
  // parses under the governor, which builds an AsyncFunction, and is a SYNTAX
3319
3432
  // ERROR on the eval fallback — so importing an SVG by URL never ran at all on
@@ -3322,12 +3435,12 @@ ${asReturningBody(code)}
3322
3435
  (async function() {
3323
3436
  let response;
3324
3437
  try {
3325
- response = await fetch('${url}');
3438
+ response = await fetch(${jsLiteral(url)});
3326
3439
  } catch (netErr) {
3327
- return { success: false, error: 'could not reach ${url} — ' + ((netErr && netErr.message) || 'network request failed') };
3440
+ return { success: false, error: 'could not reach ' + ${jsLiteral(url)} + ' — ' + ((netErr && netErr.message) || 'network request failed') };
3328
3441
  }
3329
3442
  if (!response.ok) {
3330
- return { success: false, error: 'the server refused ${url} — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
3443
+ return { success: false, error: 'the server refused ' + ${jsLiteral(url)} + ' — HTTP ' + response.status + ' ' + (response.statusText || ''), status: response.status };
3331
3444
  }
3332
3445
  let svgText = await response.text();
3333
3446
  ${INLINE_REMOTE_IMAGES}
@@ -3336,7 +3449,7 @@ ${INLINE_REMOTE_IMAGES}
3336
3449
  const _r = ${importExpr('svgText')};
3337
3450
  const imported = _r.item;
3338
3451
  if (!imported) {
3339
- return { success: false, error: 'the response from ${url} did not parse as SVG. Check the URL serves the file itself rather than a page around it.' };
3452
+ return { success: false, error: 'the response from ' + ${jsLiteral(url)} + ' did not parse as SVG. Check the URL serves the file itself rather than a page around it.' };
3340
3453
  }
3341
3454
  imported.position = new paper.Point(${position.x}, ${position.y});
3342
3455
  imported.scale(${scale});
@@ -3360,7 +3473,7 @@ ${INLINE_REMOTE_IMAGES}
3360
3473
  // Escape the SVG string for embedding in code
3361
3474
  const escapedSvg = svgString.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
3362
3475
  return `
3363
- // Import SVG string${source === 'figma' ? ' (Figma-normalised)' : ''}
3476
+ // Import SVG string${oneLine(source === 'figma' ? ' (Figma-normalised)' : '')}
3364
3477
  (async function() {
3365
3478
  let svgString = \`${escapedSvg}\`;
3366
3479
  ${INLINE_REMOTE_IMAGES}
@@ -3542,9 +3655,9 @@ throw new Error('Either svgString or url must be provided');
3542
3655
  */
3543
3656
  generateAddFilter(filterType, params = {}) {
3544
3657
  return `
3545
- // Add ${filterType} filter
3546
- const filterId = app.filterSystem.addFilter('${filterType}', ${JSON.stringify(params, null, 2)});
3547
- ({ success: true, filterId, filterType: '${filterType}' });
3658
+ // Add ${oneLine(filterType)} filter
3659
+ const filterId = app.filterSystem.addFilter(${jsLiteral(filterType)}, ${JSON.stringify(params, null, 2)});
3660
+ ({ success: true, filterId, filterType: ${jsLiteral(filterType)} });
3548
3661
  `.trim();
3549
3662
  }
3550
3663
  /**
@@ -3573,7 +3686,7 @@ const filterId = app.filterSystem.addFilter('${filterType}', ${JSON.stringify(pa
3573
3686
  return `
3574
3687
  // Create glossy 3D sphere effect
3575
3688
  const sphereGroup = new paper.Group({ parent: app.textItemGroup });
3576
- const baseColor = new paper.Color('${baseColor}');
3689
+ const baseColor = new paper.Color(${jsLiteral(baseColor)});
3577
3690
 
3578
3691
  ${castShadow ? `// Shadow underneath
3579
3692
  const shadow = new paper.Path.Ellipse({
@@ -3753,7 +3866,7 @@ paper.project.activeLayer.children.forEach(c => {
3753
3866
  const bg = new paper.Path.Rectangle({
3754
3867
  point: [0, 0],
3755
3868
  size: [paper.view.size.width, paper.view.size.height],
3756
- fillColor: '${backgroundColor}',
3869
+ fillColor: ${jsLiteral(backgroundColor)},
3757
3870
  name: 'background'
3758
3871
  });
3759
3872
  bg.sendToBack();
@@ -3763,7 +3876,7 @@ bg.sendToBack();
3763
3876
  if (backgroundGenerator) {
3764
3877
  codeParts.push(`
3765
3878
  // Execute background generator
3766
- const _gen = app.generatorRegistry?.get('${backgroundGenerator}');
3879
+ const _gen = app.generatorRegistry?.get(${jsLiteral(backgroundGenerator)});
3767
3880
  if (typeof _gen === 'function') {
3768
3881
  _gen();
3769
3882
  } else if (_gen && typeof _gen.generate === 'function') {
@@ -3795,20 +3908,20 @@ if (typeof _gen === 'function') {
3795
3908
  ...properties,
3796
3909
  };
3797
3910
  codeParts.push(`
3798
- // Create item: ${name}
3911
+ // Create item: ${oneLine(name)}
3799
3912
  (function() {
3800
- const item = app.create('${itemType}', ${JSON.stringify(sceneParams)});
3913
+ const item = app.create(${jsLiteral(itemType)}, ${JSON.stringify(sceneParams)});
3801
3914
  if (!item) {
3802
- results.items.push({ name: '${name}', error: 'app.create returned nothing for type ${itemType} — check the type and its properties.' });
3915
+ results.items.push({ name: ${jsLiteral(name)}, error: 'app.create returned nothing for type ' + ${jsLiteral(itemType)} + ' — check the type and its properties.' });
3803
3916
  return;
3804
3917
  }
3805
3918
  // create() registers and mints the id; only fall back for an older studio.
3806
3919
  const itemId = (item.data && item.data.id)
3807
3920
  ? item.data.id
3808
- : app.registerItem(item, '${itemType}', { source: 'mcp-scene' });
3921
+ : app.registerItem(item, ${jsLiteral(itemType)}, { source: 'mcp-scene' });
3809
3922
  if (item.bringToFront) item.bringToFront();
3810
- nameToId['${name}'] = itemId;
3811
- results.items.push({ name: '${name}', itemId, type: '${itemType}' });
3923
+ nameToId[${jsLiteral(name)}] = itemId;
3924
+ results.items.push({ name: ${jsLiteral(name)}, itemId, type: ${jsLiteral(itemType)} });
3812
3925
  })();`);
3813
3926
  }
3814
3927
  // Establish relations
@@ -3818,25 +3931,25 @@ if (typeof _gen === 'function') {
3818
3931
  const specProblem = relationSpecProblem(type, params);
3819
3932
  if (specProblem) {
3820
3933
  codeParts.push(`
3821
- // Refused relation: ${source} ${type} ${target}
3934
+ // Refused relation: ${oneLine(source)} ${oneLine(type)} ${oneLine(target)}
3822
3935
  (results.relationsRefused = results.relationsRefused || []).push(${JSON.stringify({ source, target, type, reason: specProblem })});
3823
3936
  results.success = false;
3824
3937
  results.error = 'refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';`);
3825
3938
  continue;
3826
3939
  }
3827
3940
  codeParts.push(`
3828
- // Add relation: ${source} ${type} ${target}
3941
+ // Add relation: ${oneLine(source)} ${oneLine(type)} ${oneLine(target)}
3829
3942
  (function() {
3830
- const sourceId = nameToId['${source}'];
3831
- const targetId = nameToId['${target}'];
3943
+ const sourceId = nameToId[${jsLiteral(source)}];
3944
+ const targetId = nameToId[${jsLiteral(target)}];
3832
3945
  if (sourceId && targetId) {
3833
3946
  const params = ${paramsStr};
3834
3947
  // A refused relation is not listed as made (owner rule 1).
3835
- if (app.addRelation(sourceId, targetId, '${type}', params)) {
3836
- results.relations.push({ source: '${source}', target: '${target}', type: '${type}' });
3948
+ if (app.addRelation(sourceId, targetId, ${jsLiteral(type)}, params)) {
3949
+ results.relations.push({ source: ${jsLiteral(source)}, target: ${jsLiteral(target)}, type: ${jsLiteral(type)} });
3837
3950
  } else {
3838
3951
  const __why = ${relationRefusalReasonJs(jsLiteral(type))};
3839
- (results.relationsRefused = results.relationsRefused || []).push({ source: '${source}', target: '${target}', type: '${type}', reason: __why || 'the studio refused it' });
3952
+ (results.relationsRefused = results.relationsRefused || []).push({ source: ${jsLiteral(source)}, target: ${jsLiteral(target)}, type: ${jsLiteral(type)}, reason: __why || 'the studio refused it' });
3840
3953
  results.success = false;
3841
3954
  results.error = 'the studio refused ' + results.relationsRefused.length + ' relation(s): ' + results.relationsRefused.map(function(r) { return r.type + ' ' + r.source + ' -> ' + r.target + ' (' + r.reason + ')'; }).join('; ') + '. The items were created.';
3842
3955
  }
@@ -3853,9 +3966,9 @@ results.error = 'refused ' + results.relationsRefused.length + ' relation(s): '
3853
3966
  ...(startTime !== undefined ? { animationDelay: startTime } : {}),
3854
3967
  });
3855
3968
  codeParts.push(`
3856
- // Apply animation: ${type} to ${target}
3969
+ // Apply animation: ${oneLine(type)} to ${oneLine(target)}
3857
3970
  (function() {
3858
- const targetId = nameToId['${target}'];
3971
+ const targetId = nameToId[${jsLiteral(target)}];
3859
3972
  if (targetId) {
3860
3973
  const targetItem = app.itemRegistry.get(targetId);
3861
3974
  if (targetItem) {
@@ -3865,8 +3978,8 @@ results.error = 'refused ' + results.relationsRefused.length + ' relation(s): '
3865
3978
  // loops with no end, so there is no duration to map and none is invented
3866
3979
  // — staggering the STARTS is what makes a scene read as choreographed
3867
3980
  // rather than everything moving at once from frame zero.
3868
- app.animate(targetItem, { animationType: '${type}', ...params });
3869
- results.animations.push({ target: '${target}', type: '${type}'${startTime !== undefined ? `, startTime: ${startTime}` : ''} });
3981
+ app.animate(targetItem, { animationType: ${jsLiteral(type)}, ...params });
3982
+ results.animations.push({ target: ${jsLiteral(target)}, type: ${jsLiteral(type)}${startTime !== undefined ? `, startTime: ${startTime}` : ''} });
3870
3983
  }
3871
3984
  }
3872
3985
  })();
@@ -3900,7 +4013,7 @@ return results;
3900
4013
  // ending in \ escapes the closing quote; 'x\\'; evil()' breaks out).
3901
4014
  const labelStr = label ? JSON.stringify(label) : 'null';
3902
4015
  return `
3903
- // Create diagram shape: ${shapeType}
4016
+ // Create diagram shape: ${oneLine(shapeType)}
3904
4017
  // POSITIONAL: createShape(shapeType, config). The type was being passed
3905
4018
  // INSIDE the config object, where nothing reads it.
3906
4019
  const shapeConfig = {
@@ -3911,12 +4024,12 @@ const shapeConfig = {
3911
4024
  style: ${styleStr}
3912
4025
  };
3913
4026
 
3914
- const shape = app.diagramSystem.createShape('${shapeType}', shapeConfig);
3915
- if (!shape) throw new Error('createShape returned nothing for shapeType "${shapeType}" — call pinepaper_get_diagram_shapes for the registered types.');
4027
+ const shape = app.diagramSystem.createShape(${jsLiteral(shapeType)}, shapeConfig);
4028
+ if (!shape) throw new Error('createShape returned nothing for shapeType ' + ${jsLiteral(shapeType)} + ' — call pinepaper_get_diagram_shapes for the registered types.');
3916
4029
  const itemId = shape.data?.registryId || shape.id;
3917
4030
  app.historyManager.saveState();
3918
4031
 
3919
- ({ itemId, shapeType: '${shapeType}', position: { x: ${position.x}, y: ${position.y} } });
4032
+ ({ itemId, shapeType: ${jsLiteral(shapeType)}, position: { x: ${position.x}, y: ${position.y} } });
3920
4033
  `.trim();
3921
4034
  }
3922
4035
  /**
@@ -3948,12 +4061,12 @@ app.historyManager.saveState();
3948
4061
  if (label)
3949
4062
  config.label = label;
3950
4063
  return `
3951
- // Connect items: ${sourceItemId} -> ${targetItemId}
3952
- const sourceItem = app.getItemById('${sourceItemId}');
3953
- const targetItem = app.getItemById('${targetItemId}');
4064
+ // Connect items: ${oneLine(sourceItemId)} -> ${oneLine(targetItemId)}
4065
+ const sourceItem = app.getItemById(${jsLiteral(sourceItemId)});
4066
+ const targetItem = app.getItemById(${jsLiteral(targetItemId)});
3954
4067
 
3955
- if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
3956
- if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
4068
+ if (!sourceItem) throw new Error('Source item not found: ' + ${jsLiteral(sourceItemId)});
4069
+ if (!targetItem) throw new Error('Target item not found: ' + ${jsLiteral(targetItemId)});
3957
4070
 
3958
4071
  const config = ${JSON.stringify(config, null, 2)};
3959
4072
  const connector = app.diagramSystem.connect(sourceItem, targetItem, config);
@@ -3971,8 +4084,8 @@ app.historyManager.saveState();
3971
4084
  // mismatch is REPORTED rather than left for a later call to trip over.
3972
4085
  ({
3973
4086
  connectorId,
3974
- sourceItemId: '${sourceItemId}',
3975
- targetItemId: '${targetItemId}',
4087
+ sourceItemId: ${jsLiteral(sourceItemId)},
4088
+ targetItemId: ${jsLiteral(targetItemId)},
3976
4089
  ...(${JSON.stringify(id ?? null)} && connectorId !== ${JSON.stringify(id ?? null)}
3977
4090
  ? { requestedId: ${JSON.stringify(id ?? null)},
3978
4091
  idHonoured: false,
@@ -3994,12 +4107,12 @@ app.historyManager.saveState();
3994
4107
  const merged = id ? { ...(config ?? {}), id } : config;
3995
4108
  const configStr = merged ? JSON.stringify(merged, null, 2) : '{}';
3996
4109
  return `
3997
- // Connect ports: ${sourceItemId}:${sourcePort} -> ${targetItemId}:${targetPort}
3998
- const sourceItem = app.getItemById('${sourceItemId}');
3999
- const targetItem = app.getItemById('${targetItemId}');
4110
+ // Connect ports: ${oneLine(sourceItemId)}:${oneLine(sourcePort)} -> ${oneLine(targetItemId)}:${oneLine(targetPort)}
4111
+ const sourceItem = app.getItemById(${jsLiteral(sourceItemId)});
4112
+ const targetItem = app.getItemById(${jsLiteral(targetItemId)});
4000
4113
 
4001
- if (!sourceItem) throw new Error('Source item not found: ${sourceItemId}');
4002
- if (!targetItem) throw new Error('Target item not found: ${targetItemId}');
4114
+ if (!sourceItem) throw new Error('Source item not found: ' + ${jsLiteral(sourceItemId)});
4115
+ if (!targetItem) throw new Error('Target item not found: ' + ${jsLiteral(targetItemId)});
4003
4116
 
4004
4117
  const config = ${configStr};
4005
4118
  // connectPorts(sourcePort, targetPort, config) takes PORT OBJECTS (gate C8,
@@ -4024,7 +4137,7 @@ if (!connector) { return { success: false, error: 'the diagram system did not cr
4024
4137
  const connectorId = connector.id || connector.data?.registryId;
4025
4138
  app.historyManager.saveState();
4026
4139
 
4027
- ({ connectorId, sourceItemId: '${sourceItemId}', sourcePort: '${sourcePort}', targetItemId: '${targetItemId}', targetPort: '${targetPort}' });
4140
+ ({ connectorId, sourceItemId: ${jsLiteral(sourceItemId)}, sourcePort: ${jsLiteral(sourcePort)}, targetItemId: ${jsLiteral(targetItemId)}, targetPort: ${jsLiteral(targetPort)} });
4028
4141
  `.trim();
4029
4142
  }
4030
4143
  /**
@@ -4042,22 +4155,22 @@ app.historyManager.saveState();
4042
4155
  if (ports !== undefined || count !== undefined) {
4043
4156
  const named = [ports !== undefined && 'ports', count !== undefined && 'count'].filter(Boolean).join(' and ');
4044
4157
  throw new Error(`add_ports cannot honour ${named}: this engine lays out a whole port SET by type `
4045
- + `(portType '${portType}'), with no per-port placement or count. Drop ${named}, or place `
4158
+ + `(portType ${jsLiteral(portType)}), with no per-port placement or count. Drop ${named}, or place `
4046
4159
  + 'individual connection points as items and join them with pinepaper_connect.');
4047
4160
  }
4048
4161
  return `
4049
- // Add ports to item: ${itemId}
4050
- const item = app.getItemById('${itemId}');
4051
- if (!item) throw new Error('Item not found: ${itemId}');
4162
+ // Add ports to item: ${oneLine(itemId)}
4163
+ const item = app.getItemById(${jsLiteral(itemId)});
4164
+ if (!item) throw new Error('Item not found: ' + ${jsLiteral(itemId)});
4052
4165
 
4053
4166
  // The second argument is a TYPE STRING, not a config object. Passing an
4054
4167
  // object made the engine iterate it as a list — "t.forEach is not a function".
4055
- const result = app.diagramSystem.addPorts(item, '${portType}');
4168
+ const result = app.diagramSystem.addPorts(item, ${jsLiteral(portType)});
4056
4169
  app.historyManager.saveState();
4057
4170
 
4058
4171
  // addPorts returns the ports it made; older builds returned a count object.
4059
4172
  const _added = Array.isArray(result) ? result.length : (result?.portsAdded ?? 0);
4060
- ({ itemId: '${itemId}', portsAdded: _added, portType: '${portType}' });
4173
+ ({ itemId: ${jsLiteral(itemId)}, portsAdded: _added, portType: ${jsLiteral(portType)} });
4061
4174
  `.trim();
4062
4175
  }
4063
4176
  /**
@@ -4069,7 +4182,7 @@ const _added = Array.isArray(result) ? result.length : (result?.portsAdded ?? 0)
4069
4182
  const itemIdsStr = itemIds ? JSON.stringify(itemIds) : 'null';
4070
4183
  const optionsStr = options ? JSON.stringify(options, null, 2) : '{}';
4071
4184
  return `
4072
- // Apply auto-layout: ${layoutType}
4185
+ // Apply auto-layout: ${oneLine(layoutType)}
4073
4186
  // Wrapped: applyLayout is async, and a bare top-level await only works on the
4074
4187
  // governed path. A paren-led IIFE also keeps the trailing-expression capture.
4075
4188
  (async function() {
@@ -4085,10 +4198,10 @@ const items = itemIds
4085
4198
  : (app.itemRegistry?.getAll?.() ?? []);
4086
4199
  if (!items.length) throw new Error('auto_layout found no items to lay out' + (itemIds ? ' for the given itemIds' : ' on the canvas'));
4087
4200
 
4088
- await app.diagramSystem.applyLayout(items, '${layoutType}', options);
4201
+ await app.diagramSystem.applyLayout(items, ${jsLiteral(layoutType)}, options);
4089
4202
  app.historyManager.saveState();
4090
4203
 
4091
- return { layoutType: '${layoutType}', itemsAffected: items.length, success: true };
4204
+ return { layoutType: ${jsLiteral(layoutType)}, itemsAffected: items.length, success: true };
4092
4205
  })();
4093
4206
  `.trim();
4094
4207
  }
@@ -4098,7 +4211,7 @@ return { layoutType: '${layoutType}', itemsAffected: items.length, success: true
4098
4211
  generateGetDiagramShapes(input) {
4099
4212
  const validated = GetDiagramShapesInputSchema.parse(input);
4100
4213
  const { category } = validated;
4101
- const categoryFilter = category ? `'${category}'` : 'null';
4214
+ const categoryFilter = category ? jsLiteral(category) : 'null';
4102
4215
  return `
4103
4216
  // Get available diagram shapes
4104
4217
  const category = ${categoryFilter};
@@ -4117,7 +4230,7 @@ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
4117
4230
  const validated = UpdateConnectorInputSchema.parse(input);
4118
4231
  const { connectorId, style, label, labelPosition } = validated;
4119
4232
  return `
4120
- // Update connector: ${connectorId}
4233
+ // Update connector: ${oneLine(connectorId)}
4121
4234
  (function() {
4122
4235
  // CONNECTORS LIVE IN THE DIAGRAM SYSTEM, NOT THE ITEM REGISTRY (gate D1,
4123
4236
  // 1.6.19): getItemById never resolves the id connect returned.
@@ -4169,7 +4282,7 @@ const shapes = category ? _lib.getByCategory(category) : _lib.getAll();
4169
4282
  const validated = RemoveConnectorInputSchema.parse(input);
4170
4283
  const { connectorId } = validated;
4171
4284
  return `
4172
- // Remove connector: ${connectorId}
4285
+ // Remove connector: ${oneLine(connectorId)}
4173
4286
  // Resolved through the diagram system (gate D1, 1.6.19), as update_connector is.
4174
4287
  const __cid = ${JSON.stringify(connectorId)};
4175
4288
  const connector = (app.diagramSystem && app.diagramSystem.getConnector(__cid)) || app.getItemById(__cid);
@@ -4178,7 +4291,7 @@ if (!connector) throw new Error('Connector not found: ' + __cid);
4178
4291
  app.diagramSystem.connectorManager.removeConnector(connector);
4179
4292
  app.historyManager.saveState();
4180
4293
 
4181
- ({ connectorId: '${connectorId}', removed: true });
4294
+ ({ connectorId: ${jsLiteral(connectorId)}, removed: true });
4182
4295
  `.trim();
4183
4296
  }
4184
4297
  /**
@@ -4207,17 +4320,17 @@ const isActive = app.diagramSystem.toggle();
4207
4320
  ({ action: 'toggle', active: isActive });
4208
4321
  `.trim();
4209
4322
  case 'setMode':
4210
- const modeStr = mode ? `'${mode}'` : "'select'";
4211
- const shapeStr = shapeType ? `, '${shapeType}'` : '';
4323
+ const modeStr = mode ? jsLiteral(mode) : "'select'";
4324
+ const shapeStr = shapeType ? `, ${jsLiteral(shapeType)}` : '';
4212
4325
  return `
4213
4326
  // Set diagram tool mode
4214
4327
  app.diagramSystem.setMode(${modeStr}${shapeStr});
4215
- ({ action: 'setMode', mode: ${modeStr}${shapeType ? `, shapeType: '${shapeType}'` : ''} });
4328
+ ({ action: 'setMode', mode: ${modeStr}${shapeType ? `, shapeType: ${jsLiteral(shapeType)}` : ''} });
4216
4329
  `.trim();
4217
4330
  default:
4218
4331
  return `
4219
4332
  // Unknown diagram mode action
4220
- throw new Error('Unknown diagram mode action: ${action}');
4333
+ throw new Error('Unknown diagram mode action: ' + ${jsLiteral(action)});
4221
4334
  `.trim();
4222
4335
  }
4223
4336
  }
@@ -4239,7 +4352,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4239
4352
  (async function() {
4240
4353
  const jobOptions = {
4241
4354
  name: ${nameStr},
4242
- screenshotPolicy: '${policyStr}',
4355
+ screenshotPolicy: ${jsLiteral(policyStr)},
4243
4356
  agentMode: true
4244
4357
  };
4245
4358
  `;
@@ -4259,7 +4372,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4259
4372
  if (canvasPreset) {
4260
4373
  code += `
4261
4374
  // Set canvas size to preset (platform name → engine preset key)
4262
- app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
4375
+ app.setCanvasSize(${jsLiteral(canvasPresetFor(canvasPreset))});
4263
4376
  // Allow canvas resize to take effect
4264
4377
  await new Promise(r => setTimeout(r, 50));
4265
4378
  `;
@@ -4303,14 +4416,14 @@ throw new Error('Unknown diagram mode action: ${action}');
4303
4416
  `;
4304
4417
  }
4305
4418
  code += `
4306
- // Return job context with canvas size${wantOntology ? ' + initial ontology' : ''}
4419
+ // Return job context with canvas size${oneLine(wantOntology ? ' + initial ontology' : '')}
4307
4420
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4308
4421
  return {
4309
4422
  success: true,
4310
4423
  jobStarted: true,
4311
4424
  name: ${nameStr},
4312
- screenshotPolicy: '${policyStr}',
4313
- canvasPreset: ${canvasPreset ? `'${canvasPreset}'` : 'null'},
4425
+ screenshotPolicy: ${jsLiteral(policyStr)},
4426
+ canvasPreset: ${canvasPreset ? jsLiteral(canvasPreset) : 'null'},
4314
4427
  canvasCleared: ${shouldClear},
4315
4428
  canvasSize: { width: _cs.width || 800, height: _cs.height || 600 }${canvasPreset ? '' : `,
4316
4429
  ...(_boundedFrom ? { note: 'the canvas was unbounded (it exported the artwork, not the board); it is now fixed at ' + (_cs.width || 800) + 'x' + (_cs.height || 600) + ', so stills and video both export that size.' } : {})`}${wantOntology ? `,
@@ -4510,7 +4623,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4510
4623
  if (canvasPreset) {
4511
4624
  code += `
4512
4625
  // Set canvas to preset (platform name → engine preset key)
4513
- app.setCanvasSize('${canvasPresetFor(canvasPreset)}');
4626
+ app.setCanvasSize(${jsLiteral(canvasPresetFor(canvasPreset))});
4514
4627
  // Allow canvas resize to take effect
4515
4628
  await new Promise(r => setTimeout(r, 50));
4516
4629
  `;
@@ -4518,14 +4631,14 @@ throw new Error('Unknown diagram mode action: ${action}');
4518
4631
  if (backgroundColor && !preserveBackground) {
4519
4632
  code += `
4520
4633
  // Set background color
4521
- app.setBackgroundColor('${backgroundColor}');
4634
+ app.setBackgroundColor(${jsLiteral(backgroundColor)});
4522
4635
  `;
4523
4636
  }
4524
4637
  code += `
4525
4638
  // Save state
4526
4639
  if (app.historyManager) app.historyManager.saveState();
4527
4640
  const _cs = app.getCanvasSize ? app.getCanvasSize() : { width: 800, height: 600 };
4528
- return { success: true, reset: true, canvasPreset: ${canvasPreset ? `'${canvasPreset}'` : 'null'}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4641
+ return { success: true, reset: true, canvasPreset: ${canvasPreset ? jsLiteral(canvasPreset) : 'null'}, canvasSize: { width: _cs.width || 800, height: _cs.height || 600 } };
4529
4642
  })();
4530
4643
  `;
4531
4644
  return code.trim();
@@ -4546,7 +4659,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4546
4659
  // anything runs; a direct caller gets the op's own failure instead.
4547
4660
  const timelines = compileBatchTimelines(operations);
4548
4661
  let code = `
4549
- // Batch execute ${operations.length} operations
4662
+ // Batch execute ${oneLine(operations.length)} operations
4550
4663
  (async function() {
4551
4664
  // An op's own verdict. Deliberately narrow, matching the server-side rule:
4552
4665
  // only an explicit success:false or ok:false is a failure, because most ops
@@ -4619,7 +4732,7 @@ throw new Error('Unknown diagram mode action: ${action}');
4619
4732
  const opItemJs = typeof opItem === 'string' && /^\$\d+$/.test(opItem) ? `__refName(${opItem.slice(1)})` : JSON.stringify(opItem);
4620
4733
  const opStep = typeof o._step === 'string' ? `op ${index}: ${o._step}` : `op ${index} (${op.type})`;
4621
4734
  code += `
4622
- // Operation ${index}: ${op.type}
4735
+ // Operation ${oneLine(index)}: ${oneLine(op.type)}
4623
4736
  try {
4624
4737
  const result${index} = await (async () => {
4625
4738
  ${opCode}
@@ -4803,14 +4916,55 @@ return { itemId: targetId, animationType: ${JSON.stringify(String(op.animationTy
4803
4916
  return `
4804
4917
  throw new Error(${JSON.stringify(String(op.relationType) + ': ' + fireIf)});
4805
4918
  `;
4919
+ // No targetId is a relation on the item alone (the engine takes a null target, as staggered_with does); a
4920
+ // custom relation whose compute ignores the target needed a stand-in before.
4921
+ const noTarget = op.targetId === undefined || op.targetId === null || op.targetId === '';
4922
+ // A NAME PATTERN AND AN UPDATE (mcp-server#37). sourceId "dot-*" binds every item named like it, in name
4923
+ // order (dot-2 before dot-10), each with index and count added to its params, so "12 → 20 dots" is
4924
+ // adding members. update: true changes the params of the relation already bound instead of adding one.
4925
+ const glob = typeof op.sourceId === 'string' && op.sourceId.includes('*') && !op.sourceId.startsWith('$');
4926
+ if (glob || op.update === true) {
4927
+ const type = jsLiteral(String(op.relationType));
4928
+ const members = glob
4929
+ ? `const __re = new RegExp(${jsLiteral('^' + String(op.sourceId).split('*').map((p) => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')});
4930
+ const __members = app.itemRegistry.getAll()
4931
+ .map((e) => ({ id: e.id || e.itemId, name: (e.item && ((e.item.data && e.item.data.name) || e.item.name)) || '' }))
4932
+ .filter((m) => __re.test(m.name))
4933
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
4934
+ if (!__members.length) throw new Error('no item is named like ' + ${jsLiteral(op.sourceId)} + ': name items with properties.name');`
4935
+ : `const __members = [{ id: ${srcRef} }];
4936
+ if (!app.getItemById(__members[0].id)) throw new Error('Source not found: ' + __members[0].id);`;
4937
+ return `
4938
+ ${members}
4939
+ const targetId = ${noTarget ? 'null' : tgtRef};
4940
+ const target = ${noTarget ? 'null' : 'app.getItemById(targetId)'};
4941
+ ${noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n"}${op.update === true ? "if (typeof app.modifyRelationParams !== 'function') throw new Error('this studio cannot change a bound relation (app.modifyRelationParams): update PinePaper Studio');\n" : ''}const __done = [], __failed = [];
4942
+ __members.forEach((m, i) => {
4943
+ const __p = Object.assign({}, ${relOpts}${glob ? ', { index: i, count: __members.length }' : ''});
4944
+ const ok = ${op.update === true
4945
+ ? `app.modifyRelationParams(m.id, targetId, ${type}, __p) || (targetId === null && app.modifyRelationParams(m.id, undefined, ${type}, __p))`
4946
+ : glob
4947
+ // Already bound (a re-run after adding members): its params change, so index and count follow the new set.
4948
+ ? `((app.relationRegistry && app.relationRegistry.getAssociations(m.id, ${type}).some((a) => a.target === targetId || (targetId === null && a.target == null)) && typeof app.modifyRelationParams === 'function')
4949
+ ? (app.modifyRelationParams(m.id, targetId, ${type}, __p) || (targetId === null && app.modifyRelationParams(m.id, undefined, ${type}, __p)))
4950
+ : app.addRelation(app.getItemById(m.id), target, ${type}, __p))`
4951
+ : `app.addRelation(app.getItemById(m.id), target, ${type}, __p)`};
4952
+ (ok ? __done : __failed).push(m.id);
4953
+ });
4954
+ if (!__done.length) {
4955
+ const __why = ${op.update === true ? `'no ' + ${type} + ' relation is bound from ' + __failed.join(', ') + (targetId === null ? '' : ' to ' + targetId) + ' to change: bind it first (a relation op without update)'` : relationRefusalReasonJs(type)};
4956
+ return { success: false, relationType: ${type}, sourceIds: __failed, error: ${op.update === true ? '__why' : `'the studio refused the ' + ${type} + ' relation for ' + __failed.join(', ') + ' — nothing was bound' + (__why ? ': ' + __why : '.')`} };
4957
+ }
4958
+ return { success: true, relationType: ${type}, ${op.update === true ? 'updated' : 'bound'}: __done, targetId${glob ? ', count: __members.length' : ''}${(() => { const w = relationOptionWarning(op.relationType, op.relationOptions, ['index', 'count']); return w ? `, optionWarning: ${jsLiteral(w)}` : ''; })()}, ...(__failed.length ? { failed: __failed, warning: __failed.length + ' of ' + __members.length + ' were not ${op.update === true ? 'changed' : 'bound'}: ' + __failed.join(', ') } : {}) };
4959
+ `;
4960
+ }
4806
4961
  return `
4807
4962
  const sourceId = ${srcRef};
4808
- const targetId = ${tgtRef};
4963
+ const targetId = ${noTarget ? 'null' : tgtRef};
4809
4964
  const source = app.getItemById(sourceId);
4810
- const target = app.getItemById(targetId);
4965
+ const target = ${noTarget ? 'null' : 'app.getItemById(targetId)'};
4811
4966
  if (!source) throw new Error('Source not found: ' + sourceId);
4812
- if (!target) throw new Error('Target not found: ' + targetId);
4813
- const relationId = app.addRelation(source, target, ${JSON.stringify(String(op.relationType))}, ${relOpts});
4967
+ ${noTarget ? '' : "if (!target) throw new Error('Target not found: ' + targetId);\n"}const relationId = app.addRelation(source, target, ${JSON.stringify(String(op.relationType))}, ${relOpts});
4814
4968
  // NOT BOUND IS NOT A SUCCESS (owner rule 1, B119). addRelation answers false
4815
4969
  // when it refuses — spring_follow "not loaded yet" — and this op returned
4816
4970
  // { relationId: false } with nothing marking it failed, so the batch said
@@ -4820,7 +4974,7 @@ if (!relationId) {
4820
4974
  return { success: false, relationId: false, sourceId, targetId, relationType: ${JSON.stringify(op.relationType)},
4821
4975
  error: 'the studio refused the ' + ${JSON.stringify(op.relationType)} + ' relation between ' + sourceId + ' and ' + targetId + ' — nothing was bound' + (__why ? ': ' + __why : '.') };
4822
4976
  }
4823
- return { relationId, sourceId, targetId, relationType: ${JSON.stringify(String(op.relationType))} };
4977
+ return { relationId, sourceId, targetId, relationType: ${JSON.stringify(String(op.relationType))}${(() => { const w = relationOptionWarning(op.relationType, op.relationOptions); return w ? `, warning: ${jsLiteral(w)}` : ''; })()} };
4824
4978
  `;
4825
4979
  case 'delete':
4826
4980
  const delRef = batchRef(op.itemId);
@@ -4876,7 +5030,7 @@ return { success: true, width: (_cs && _cs.width) || ${w}, height: (_cs && _cs.h
4876
5030
  const foldTarget = mergePlan?.foldedInto.get(index);
4877
5031
  if (foldTarget !== undefined) {
4878
5032
  return `
4879
- // Keyframes merged into operation ${foldTarget} (same target). A keyframe track
5033
+ // Keyframes merged into operation ${oneLine(foldTarget)} (same target). A keyframe track
4880
5034
  // is replaced wholesale, so emitting both here would silently drop one.
4881
5035
  return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget} };
4882
5036
  `;
@@ -4906,7 +5060,7 @@ return { itemId: ${batchRef(op.itemId)}, merged: true, mergedInto: ${foldTarget}
4906
5060
  }
4907
5061
  const kfUnits = op.timeUnits;
4908
5062
  if (kfUnits === 'seconds' || kfUnits === 'ms')
4909
- kfExtra.push(`timeUnits: '${kfUnits}'`);
5063
+ kfExtra.push(`timeUnits: ${jsLiteral(kfUnits)}`);
4910
5064
  const kfExtraStr = kfExtra.length > 0 ? `, ${kfExtra.join(', ')}` : '';
4911
5065
  const kfTextKeysJs = unanimatedTextKeysJs((kfSource || []).flatMap((k) => Object.keys(k.properties ?? {})));
4912
5066
  const kfTextCheck = kfTextKeysJs ? `\nconst _kfIgnored = ${kfTextKeysJs};` : '';
@@ -4933,12 +5087,12 @@ const targetId = ${maskItemRef};
4933
5087
  const item = app.getItemById(targetId);
4934
5088
  if (!item) throw new Error('Item not found: ' + targetId);
4935
5089
  if (!app.applyAnimatedMask) throw new Error('Mask system not available');
4936
- const maskedGroup = app.applyAnimatedMask(item, '${preset}', ${maskOpts});
5090
+ const maskedGroup = app.applyAnimatedMask(item, ${jsLiteral(preset)}, ${maskOpts});
4937
5091
  const maskRej = maskedGroup && maskedGroup.data && maskedGroup.data.maskAnimationRejected;
4938
5092
  if (maskRej) {
4939
5093
  return { success: false, itemId: targetId, error: 'Masked, but animation preset "' + maskRej.requested + '" is unknown — it will NOT animate.', requested: maskRej.requested, known: maskRej.known };
4940
5094
  }
4941
- return { success: !!maskedGroup, itemId: targetId, preset: '${preset}' };
5095
+ return { success: !!maskedGroup, itemId: targetId, preset: ${jsLiteral(preset)} };
4942
5096
  `;
4943
5097
  }
4944
5098
  case 'apply_effect': {
@@ -4952,12 +5106,12 @@ if (!item) throw new Error('Item not found: ' + targetId);
4952
5106
  // shape, so an effect the engine refused — an unknown preset, a facade that
4953
5107
  // is not loaded — was recorded as applied. The batch cannot report what the
4954
5108
  // op never tells it.
4955
- const _eff = app.applyEffect(item, '${op.effectType || 'sparkle'}', ${effParams});
5109
+ const _eff = app.applyEffect(item, ${jsLiteral(op.effectType || 'sparkle')}, ${effParams});
4956
5110
  if (_eff && (_eff.success === false || _eff.ok === false)) {
4957
- return { success: false, itemId: targetId, effectType: '${op.effectType || 'sparkle'}',
5111
+ return { success: false, itemId: targetId, effectType: ${jsLiteral(op.effectType || 'sparkle')},
4958
5112
  error: _eff.error || _eff.reason || 'the effect was refused without a reason' };
4959
5113
  }
4960
- return { itemId: targetId, effectType: '${op.effectType || 'sparkle'}' };
5114
+ return { itemId: targetId, effectType: ${jsLiteral(op.effectType || 'sparkle')} };
4961
5115
  `;
4962
5116
  }
4963
5117
  case 'play_timeline': {
@@ -5005,7 +5159,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5005
5159
  if (typeof length === 'number')
5006
5160
  range.length = length;
5007
5161
  return `
5008
- // Read export chunk at ${offset}
5162
+ // Read export chunk at ${oneLine(offset)}
5009
5163
  (async function() {
5010
5164
  if (!app.exportEngine || typeof app.exportEngine.readExport !== 'function') {
5011
5165
  return { ok: false, reason: 'app.exportEngine.readExport unavailable — update FxTool' };
@@ -5050,9 +5204,13 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5050
5204
  }
5051
5205
  generateAgentExport(input) {
5052
5206
  const validated = AgentExportInputSchema.parse(input);
5053
- 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 } = validated;
5207
+ 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;
5054
5208
  // Encoder options (FxTool 16719759); the schema keeps them to mp4 / webm.
5055
5209
  const videoEncodeOpts = {};
5210
+ // A RANGE of the timeline (FxTool #250): frames for t in [startTime, startTime + duration), each exactly the frame
5211
+ // a full export has at that time (the engine renders the frames before startTime without encoding them).
5212
+ if (startTime !== undefined)
5213
+ videoEncodeOpts.startTime = startTime;
5056
5214
  if (broadcast)
5057
5215
  videoEncodeOpts.broadcast = true;
5058
5216
  // Headroom defaults to 12 with broadcast (retest of caa9560): on a
@@ -5172,10 +5330,10 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5172
5330
  if (exportFormat === 'srt' || exportFormat === 'vtt' || exportFormat === 'scc') {
5173
5331
  const fmt = exportFormat;
5174
5332
  return `
5175
- // Export: captions (${fmt}) from text items with a lifetime
5333
+ // Export: captions (${oneLine(fmt)}) from text items with a lifetime
5176
5334
  (function() {
5177
5335
  if (!app.itemRegistry || typeof app.itemRegistry.getAll !== 'function') {
5178
- return { success: false, format: '${fmt}', error: 'this studio cannot list items (app.itemRegistry.getAll) — update PinePaper Studio.' };
5336
+ return { success: false, format: ${jsLiteral(fmt)}, error: 'this studio cannot list items (app.itemRegistry.getAll) — update PinePaper Studio.' };
5179
5337
  }
5180
5338
  const texts = app.itemRegistry.getAll().filter(function(e) {
5181
5339
  const it = e && e.item;
@@ -5183,7 +5341,7 @@ return { success: true, action: 'seek', time: ${op.time || 0} };
5183
5341
  });
5184
5342
  const timed = texts.filter(function(e) { return e.item.data && typeof e.item.data.bornAt === 'number'; });
5185
5343
  if (!timed.length) {
5186
- return { success: false, format: '${fmt}', error: 'no captions to export: no text item has a lifetime. Stage each caption with properties.bornAt / ttl (seconds) on create_item or modify_item, then export ${fmt}.', untimedText: texts.length };
5344
+ return { success: false, format: ${jsLiteral(fmt)}, error: 'no captions to export: no text item has a lifetime. Stage each caption with properties.bornAt / ttl (seconds) on create_item or modify_item, then export ${fmt}.', untimedText: texts.length };
5187
5345
  }
5188
5346
  const end = ${videoDuration};
5189
5347
  const ts = function(t, sep) {
@@ -5203,7 +5361,7 @@ ${fmt === 'scc' ? ` return { success: true, format: 'scc', sccCues: cues, cues:
5203
5361
  return ${fmt === 'srt' ? `(i + 1) + '\\n' + ts(c.start, ',') + ' --> ' + ts(c.stop, ',')` : `ts(c.start, '.') + ' --> ' + ts(c.stop, '.')`} + '\\n' + c.text;
5204
5362
  }).join('\\n\\n') + '\\n';
5205
5363
  const text = ${fmt === 'vtt' ? `'WEBVTT\\n\\n' + body` : 'body'};
5206
- return { success: true, format: '${fmt}', data: text, mimeType: '${fmt === 'srt' ? 'application/x-subrip' : 'text/vtt'}', size: text.length, cues: cues.length, untimedText: texts.length - timed.length };
5364
+ return { success: true, format: ${jsLiteral(fmt)}, data: text, mimeType: ${jsLiteral(fmt === 'srt' ? 'application/x-subrip' : 'text/vtt')}, size: text.length, cues: cues.length, untimedText: texts.length - timed.length };
5207
5365
  })();`.trim();
5208
5366
  }
5209
5367
  if (exportFormat === 'wav') {
@@ -5253,7 +5411,7 @@ ${fmt === 'scc' ? ` return { success: true, format: 'scc', sccCues: cues, cues:
5253
5411
  bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
5254
5412
  }
5255
5413
  return {
5256
- success: true, format: 'wav', platform: '${platform}',
5414
+ success: true, format: 'wav', platform: ${jsLiteral(platform)},
5257
5415
  data: 'data:audio/wav;base64,' + btoa(bin),
5258
5416
  size: bytes.length,
5259
5417
  duration: r.duration, sampleRate: r.sampleRate, sounds: r.sounds, placed: r.placed,
@@ -5264,12 +5422,14 @@ ${fmt === 'scc' ? ` return { success: true, format: 'scc', sccCues: cues, cues:
5264
5422
  })();`.trim();
5265
5423
  }
5266
5424
  return `
5267
- // Smart export for ${platform}
5425
+ // Smart export for ${oneLine(platform)}
5268
5426
  (async function() {${ENSURE_EXPORT_ENGINE}
5269
- const platform = '${platform}';
5270
- const format = '${exportFormat}';
5271
- const quality = '${qualityLevel}';
5272
- const framing = '${framing}';
5427
+ const platform = ${jsLiteral(platform)};
5428
+ const format = ${jsLiteral(exportFormat)};${startTime !== undefined ? `
5429
+ if (format !== 'mp4' && format !== 'webm') return { success: false, format, error: 'startTime exports a range of the timeline, which only mp4 and webm can: ' + format + ' would start from 0.' };
5430
+ if (!(app.exportEngine && app.exportEngine.supportsTimeRange === true)) return { success: false, format, error: 'this studio cannot export a range of the timeline (startTime): update PinePaper Studio, or export from 0.' };` : ''}
5431
+ const quality = ${jsLiteral(qualityLevel)};
5432
+ const framing = ${jsLiteral(framing)};
5273
5433
  // An explicit fps wins over the tier's. The tiers bundle frame rate with
5274
5434
  // compression (draft 15 / standard 30 / high 60), so "higher quality" also
5275
5435
  // doubled the frame count and the render time — a surprise the schema used
@@ -6127,6 +6287,28 @@ ${stillTime === undefined ? ` if (['png', 'jpg', 'webp', 'svg', 'pdf'].indexOf(
6127
6287
  }
6128
6288
 
6129
6289
  if (result && result.success) Object.assign(result, fidelity(format));
6290
+ // MOTION FROM RELATIONS IS MOTION. The studio's check counts keyframe tracks
6291
+ // and simple animations only, so an orbit (or attached_to a moving item, a
6292
+ // generator, the camera) exported with animated:false while the frames moved,
6293
+ // and callers rebuilt the scene with keyframes. When it says false, the
6294
+ // frames decide: four captures across the export. The times are uneven so a
6295
+ // loop cannot land on whole periods and look still. After the export, so it
6296
+ // cannot touch the file. A capture leaves the pose of the last time it drew,
6297
+ // so the samples end on 0 again: the canvas is left at rest, as found.
6298
+ if (result && result.success && ['mp4', 'webm', 'gif', 'apng'].indexOf(format) !== -1
6299
+ && result.fidelity && result.fidelity.checked && result.fidelity.checked.animated === false
6300
+ && typeof app.captureFramesAt === 'function') {
6301
+ try {
6302
+ const __mt = [0, 0.17, 0.43, 0.71].map((k) => Math.round(k * ${videoDuration} * 1000) / 1000);
6303
+ const __mcap = typeof app.captureFramesAtAsync === 'function' ? app.captureFramesAtAsync.bind(app) : app.captureFramesAt.bind(app);
6304
+ const __mf = await __mcap(__mt.concat([0]), { seed: 0, capture: (c) => (typeof app.captureFrameDataURL === 'function' ? app.captureFrameDataURL() : (c && c.toDataURL ? c.toDataURL() : '')) });
6305
+ const __mu = new Set((__mf || []).filter((u) => typeof u === 'string' && u.length > 0));
6306
+ if (__mu.size > 1) {
6307
+ result.fidelity.checked.animated = true;
6308
+ result.fidelity.checked.motion = 'frames differ across the export (sampled at ' + __mt.join(', ') + ' s): the motion comes from relations, generators or the camera, not keyframe tracks';
6309
+ }
6310
+ } catch (e) { /* the studio's answer stands */ }
6311
+ }
6130
6312
  if (result && result.success && __lint && __lint.length && !result.lintWarnings) {
6131
6313
  result.lintWarnings = __lint;
6132
6314
  result.fidelity = result.fidelity || { warnings: [] };
@@ -6416,7 +6598,7 @@ ${(() => {
6416
6598
  if (__bs.stale) {
6417
6599
  result.stale = true;
6418
6600
  result.fidelity = result.fidelity || { warnings: [] };
6419
- 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.' }]);
6601
+ 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, so it may differ from what the live build makes. pinepaper_clear_canvas (or pinepaper_agent_start_job) loads the live build; it empties the canvas, so rebuild the scene and export again if the difference matters.' }]);
6420
6602
  }
6421
6603
  }
6422
6604
  } catch (_) { /* older studio */ }
@@ -6428,7 +6610,7 @@ ${(() => {
6428
6610
  if (__rl && __rl.enabled) {
6429
6611
  result.fidelity = result.fidelity || { warnings: [] };
6430
6612
  if (['png', 'jpg', 'webp', 'pdf', 'mp4', 'webm'].indexOf(${JSON.stringify(format)}) === -1) {
6431
- result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'relight_not_applied', message: 'a relight rig is set, but ${format} is not relit. Export png, pdf, mp4 or webm to keep the lighting.' }]);
6613
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'relight_not_applied', message: 'a relight rig is set, but ' + ${jsLiteral(format)} + ' is not relit. Export png, pdf, mp4 or webm to keep the lighting.' }]);
6432
6614
  } else {
6433
6615
  result.relight = { applied: true, lights: (__rl.lights || []).length };
6434
6616
  }
@@ -6659,7 +6841,7 @@ ${(() => {
6659
6841
  const validated = AnimateLetterCollageInputSchema.parse(input);
6660
6842
  const { collageId, animationType, staggerDelay = 0.1, animationSpeed = 1, } = validated;
6661
6843
  return `
6662
- // Animate letter collage: ${collageId}
6844
+ // Animate letter collage: ${oneLine(collageId)}
6663
6845
  (function() {
6664
6846
  const collageId = ${JSON.stringify(collageId)};
6665
6847
  const animationType = ${JSON.stringify(animationType)};
@@ -6756,10 +6938,10 @@ ${(() => {
6756
6938
  }
6757
6939
 
6758
6940
  try {
6759
- const result = await app.mapSystem.loadMap('${mapId}', ${optionsStr});
6941
+ const result = await app.mapSystem.loadMap(${jsLiteral(mapId)}, ${optionsStr});
6760
6942
  return {
6761
6943
  success: true,
6762
- mapId: result.mapId || '${mapId}',
6944
+ mapId: result.mapId || ${jsLiteral(mapId)},
6763
6945
  // regionPaths is a Map: .length was undefined, so every load reported
6764
6946
  // regions: 0 (gate D3). size, with the feature count as a fallback.
6765
6947
  regions: (result.regions && (result.regions.size ?? result.regions.length)) || (result.geoData && result.geoData.features ? result.geoData.features.length : 0),
@@ -7176,7 +7358,7 @@ ${(() => {
7176
7358
  }
7177
7359
 
7178
7360
  try {
7179
- const result = await app.mapSystem.importCustomMap('${validated.url}', ${optionsStr});
7361
+ const result = await app.mapSystem.importCustomMap(${jsLiteral(validated.url)}, ${optionsStr});
7180
7362
  // A summary, not the engine's return: that carries the Paper group and
7181
7363
  // the region Map, which serialised into a raw Paper.js dump (gate N1).
7182
7364
  const __r = result && result.regions;
@@ -7294,7 +7476,7 @@ ${(() => {
7294
7476
  loop: ${validated.loop !== false},
7295
7477
  colors: ${colorsStr},${validated.waveSpeed !== undefined ? `
7296
7478
  waveSpeed: ${validated.waveSpeed},` : ''}
7297
- waveDirection: '${validated.waveDirection || 'horizontal'}'
7479
+ waveDirection: ${jsLiteral(validated.waveDirection || 'horizontal')}
7298
7480
  });
7299
7481
  return { success: true, animatedRegions: result?.animatedRegions || [], totalRegions: result?.totalRegions || 0 };
7300
7482
  } catch (error) {
@@ -7366,7 +7548,7 @@ ${(() => {
7366
7548
  includeSelected: ${validated.includeSelected !== false},
7367
7549
  includeColors: ${validated.includeColors !== false},
7368
7550
  download: ${validated.download === true},
7369
- filename: '${validated.filename || 'map-regions.csv'}'
7551
+ filename: ${jsLiteral(validated.filename || 'map-regions.csv')}
7370
7552
  });
7371
7553
  return { success: true, csv: result?.csv || '', regionCount: result?.regionCount || 0 };
7372
7554
  } catch (error) {
@@ -7623,7 +7805,7 @@ ${(() => {
7623
7805
 
7624
7806
  const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
7625
7807
  if (!item) {
7626
- return { success: false, error: 'Item ${itemId} not found' };
7808
+ return { success: false, error: 'Item ' + ${jsLiteral(itemId)} + ' not found' };
7627
7809
  }
7628
7810
 
7629
7811
  try {
@@ -7652,7 +7834,7 @@ ${(() => {
7652
7834
 
7653
7835
  const item = (app.getItemById && app.getItemById(${JSON.stringify(itemId)})) || (app.itemRegistry && app.itemRegistry.get && app.itemRegistry.get(${JSON.stringify(itemId)}) && (app.itemRegistry.get(${JSON.stringify(itemId)}).item || app.itemRegistry.get(${JSON.stringify(itemId)})));
7654
7836
  if (!item) {
7655
- return { success: false, error: 'Item ${itemId} not found' };
7837
+ return { success: false, error: 'Item ' + ${jsLiteral(itemId)} + ' not found' };
7656
7838
  }
7657
7839
 
7658
7840
  try {
@@ -8025,10 +8207,10 @@ ${(() => {
8025
8207
  const paramsStr = validated.params ? JSON.stringify(validated.params) : '{}';
8026
8208
  const templatesStr = validated.templates ? JSON.stringify(validated.templates) : '[]';
8027
8209
  return `
8028
- // Register custom relation: ${validated.name}
8210
+ // Register custom relation: ${oneLine(validated.name)}
8029
8211
  (function() {
8030
8212
  try {
8031
- app.registerRelationRule('${validated.name}', {
8213
+ app.registerRelationRule(${jsLiteral(validated.name)}, {
8032
8214
  description: ${JSON.stringify(validated.description || '')},
8033
8215
  params: ${paramsStr},
8034
8216
  compute: (ctx) => {
@@ -8042,7 +8224,11 @@ ${(() => {
8042
8224
  continuous: ${validated.continuous !== false},
8043
8225
  priority: ${validated.priority || 0}
8044
8226
  });
8045
- return { success: true, relationName: '${validated.name}', message: 'Custom relation registered successfully' };
8227
+ // The definition as given, kept on the page so a later call can read it back (pinepaper_lint_scene
8228
+ // customRelations): the engine keeps the compiled rule, not the text an agent would edit.
8229
+ window.__ppCustomRelations = window.__ppCustomRelations || {};
8230
+ window.__ppCustomRelations[${jsLiteral(validated.name)}] = ${jsLiteral({ name: validated.name, ...(validated.description ? { description: validated.description } : {}), params: validated.params ?? {}, computeFunction: validated.computeFunction, applyFunction: validated.applyFunction })};
8231
+ return { success: true, relationName: ${jsLiteral(validated.name)}, message: 'Custom relation registered successfully' };
8046
8232
  } catch (error) {
8047
8233
  return { success: false, error: error.message };
8048
8234
  }
@@ -8073,7 +8259,7 @@ ${(() => {
8073
8259
  // __ppValue with the scene lint beside it, and a throw comes back as { step, itemId, reason }, never a bare
8074
8260
  // message (and never "undefined" for a thrown non-Error).
8075
8261
  return `
8076
- // ${description}
8262
+ // ${oneLine(description)}
8077
8263
  (async function() {
8078
8264
  // THE SCENE LINT AFTER THE CODE (FxTool lintScene().warnings): what the scene will show wrong without anything
8079
8265
  // failing (a centred title that runs out of the frame). Carried out under __ppLint; the server moves it beside the
@@ -8129,21 +8315,21 @@ ${(() => {
8129
8315
  // Per-preset options. Custom keyframes are an animated-mask extension —
8130
8316
  // when supplied, fall through to maskingSystem.applyCustomMask instead.
8131
8317
  return `
8132
- // Apply animated mask to ${validated.itemId}
8318
+ // Apply animated mask to ${oneLine(validated.itemId)}
8133
8319
  (function() {
8134
8320
  if (!app.applyAnimatedMask) {
8135
8321
  return { success: false, error: 'Mask system not available' };
8136
8322
  }
8137
- const item = app.getItemById('${validated.itemId}');
8138
- if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
8323
+ const item = app.getItemById(${jsLiteral(validated.itemId)});
8324
+ if (!item) return { success: false, error: 'Item not found: ' + ${jsLiteral(validated.itemId)} };
8139
8325
 
8140
8326
  try {
8141
8327
  const mergedOpts = Object.assign({}, ${optionsStr}, ${maskOptionsStr});
8142
8328
  let maskedGroup;
8143
8329
  if (${keyframesStr} && app.maskingSystem && app.maskingSystem.applyCustomMask) {
8144
- maskedGroup = app.maskingSystem.applyCustomMask(item, '${customShape}', ${keyframesStr}, mergedOpts);
8330
+ maskedGroup = app.maskingSystem.applyCustomMask(item, ${jsLiteral(customShape)}, ${keyframesStr}, mergedOpts);
8145
8331
  } else {
8146
- maskedGroup = app.applyAnimatedMask(item, '${preset}', mergedOpts);
8332
+ maskedGroup = app.applyAnimatedMask(item, ${jsLiteral(preset)}, mergedOpts);
8147
8333
  }
8148
8334
  // A mask with an unknown ANIMATION preset still masks — the item just never
8149
8335
  // animates — so !!maskedGroup is true either way. The engine records which.
@@ -8151,13 +8337,13 @@ ${(() => {
8151
8337
  if (rej) {
8152
8338
  return {
8153
8339
  success: false,
8154
- itemId: '${validated.itemId}',
8340
+ itemId: ${jsLiteral(validated.itemId)},
8155
8341
  error: 'Masked, but the animation preset "' + rej.requested + '" is unknown — it will NOT animate.',
8156
8342
  requested: rej.requested,
8157
8343
  known: rej.known,
8158
8344
  };
8159
8345
  }
8160
- return { success: !!maskedGroup, itemId: '${validated.itemId}', preset: '${preset}' };
8346
+ return { success: !!maskedGroup, itemId: ${jsLiteral(validated.itemId)}, preset: ${jsLiteral(preset)} };
8161
8347
  } catch (error) {
8162
8348
  return { success: false, error: error.message };
8163
8349
  }
@@ -8169,17 +8355,17 @@ ${(() => {
8169
8355
  const keyframesStr = JSON.stringify(validated.keyframes);
8170
8356
  const maskOptionsStr = validated.maskOptions ? JSON.stringify(validated.maskOptions) : '{}';
8171
8357
  return `
8172
- // Apply custom mask to ${validated.itemId}
8358
+ // Apply custom mask to ${oneLine(validated.itemId)}
8173
8359
  (function() {
8174
8360
  if (!app.maskingSystem || !app.maskingSystem.applyCustomMask) {
8175
8361
  return { success: false, error: 'Mask system not available' };
8176
8362
  }
8177
- const item = app.getItemById('${validated.itemId}');
8178
- if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
8363
+ const item = app.getItemById(${jsLiteral(validated.itemId)});
8364
+ if (!item) return { success: false, error: 'Item not found: ' + ${jsLiteral(validated.itemId)} };
8179
8365
 
8180
8366
  try {
8181
- const maskedGroup = app.maskingSystem.applyCustomMask(item, '${validated.maskType}', ${keyframesStr}, ${maskOptionsStr});
8182
- return { success: !!maskedGroup, itemId: '${validated.itemId}', maskType: '${validated.maskType}' };
8367
+ const maskedGroup = app.maskingSystem.applyCustomMask(item, ${jsLiteral(validated.maskType)}, ${keyframesStr}, ${maskOptionsStr});
8368
+ return { success: !!maskedGroup, itemId: ${jsLiteral(validated.itemId)}, maskType: ${jsLiteral(validated.maskType)} };
8183
8369
  } catch (error) {
8184
8370
  return { success: false, error: error.message };
8185
8371
  }
@@ -8189,20 +8375,20 @@ ${(() => {
8189
8375
  generateRemoveMask(input) {
8190
8376
  const validated = RemoveMaskInputSchema.parse(input);
8191
8377
  return `
8192
- // Remove mask from ${validated.itemId}
8378
+ // Remove mask from ${oneLine(validated.itemId)}
8193
8379
  (function() {
8194
8380
  if (!app.removeMask) {
8195
8381
  return { success: false, error: 'Mask system not available' };
8196
8382
  }
8197
8383
  // The item id may point at either the masked group OR the original item;
8198
8384
  // app.removeMask expects the masked group, so resolve and walk up if needed.
8199
- const item = app.getItemById('${validated.itemId}');
8200
- if (!item) return { success: false, error: 'Item not found: ${validated.itemId}' };
8385
+ const item = app.getItemById(${jsLiteral(validated.itemId)});
8386
+ if (!item) return { success: false, error: 'Item not found: ' + ${jsLiteral(validated.itemId)} };
8201
8387
  const maskedGroup = (item.data && item.data.isMaskedGroup) ? item : (item.parent && item.parent.data && item.parent.data.isMaskedGroup ? item.parent : item);
8202
8388
 
8203
8389
  try {
8204
8390
  app.removeMask(maskedGroup);
8205
- return { success: true, itemId: '${validated.itemId}', message: 'Mask removed' };
8391
+ return { success: true, itemId: ${jsLiteral(validated.itemId)}, message: 'Mask removed' };
8206
8392
  } catch (error) {
8207
8393
  return { success: false, error: error.message };
8208
8394
  }
@@ -8231,7 +8417,7 @@ ${(() => {
8231
8417
  return { success: false, error: 'app.listAnimatableProperties unavailable — update the studio. The keyframe tool lists the properties it accepts in its own description meanwhile.' };
8232
8418
  }
8233
8419
  const item = app.getItemById(${JSON.stringify(itemId)});
8234
- if (!item) { return { success: false, error: 'no item ${itemId}' }; }
8420
+ if (!item) { return { success: false, error: 'no item ' + ${jsLiteral(itemId)} }; }
8235
8421
  const r = app.listAnimatableProperties(item);
8236
8422
  return { success: true, ...r };
8237
8423
  })();`.trim();
@@ -8329,7 +8515,7 @@ ${(() => {
8329
8515
  generateApplyTemplate(input) {
8330
8516
  const { templateId, category, listOnly } = input;
8331
8517
  if (listOnly || !templateId) {
8332
- const categoryFilter = category ? `'${category}'` : 'null';
8518
+ const categoryFilter = category ? jsLiteral(category) : 'null';
8333
8519
  return `
8334
8520
  // List available templates
8335
8521
  (async function() {
@@ -8357,7 +8543,7 @@ ${(() => {
8357
8543
  `.trim();
8358
8544
  }
8359
8545
  return `
8360
- // Apply template: ${templateId}
8546
+ // Apply template: ${oneLine(templateId)}
8361
8547
  (async function() {
8362
8548
  if (!app.templateManager) {
8363
8549
  return { error: 'Template manager not available. Make sure PinePaper Studio is loaded.' };
@@ -8404,7 +8590,7 @@ ${(() => {
8404
8590
  let fidelity = {};
8405
8591
  try {
8406
8592
  const tpl = (app.templateManager.getAllTemplates() || [])
8407
- .find(function(t) { return t && (t.id === '${templateId}' || t.templateId === '${templateId}'); });
8593
+ .find(function(t) { return t && (t.id === ${jsLiteral(templateId)} || t.templateId === ${jsLiteral(templateId)}); });
8408
8594
  const items = tpl && tpl.data && Array.isArray(tpl.data.items) ? tpl.data.items : null;
8409
8595
  const declared = items ? items.filter(function(i) { return i && i.type === 'group' && i.paperJSON; }).length : 0;
8410
8596
  if (declared > 0 && app.itemRegistry && typeof app.itemRegistry.getAll === 'function') {
@@ -8425,7 +8611,7 @@ ${(() => {
8425
8611
  }
8426
8612
  } catch (e) { /* a check that cannot run must not fail the load */ }
8427
8613
 
8428
- return { success: true, templateId: '${templateId}', message: 'Template applied successfully. Canvas has been replaced with template content.', ...fidelity };
8614
+ return { success: true, templateId: ${jsLiteral(templateId)}, message: 'Template applied successfully. Canvas has been replaced with template content.', ...fidelity };
8429
8615
  } catch (e) {
8430
8616
  return { error: 'Failed to apply template: ' + e.message };
8431
8617
  }
@@ -8475,7 +8661,7 @@ ${(() => {
8475
8661
  // The bytes then go in as a DATA URL, which img.src loads without touching
8476
8662
  // the network again — so a host that allows fetch but blocks the image
8477
8663
  // load (CORS on <img>, a tainted-canvas refusal) now works too.
8478
- let src = '${url}';
8664
+ let src = ${jsLiteral(url)};
8479
8665
  // A STAGED KEY, not the bytes. The handler writes anything over ~64KB to
8480
8666
  // window.__ppStage and passes the key instead, because the governor's
8481
8667
  // loop-guard transform bails on a code string that large and takes the
@@ -8537,7 +8723,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8537
8723
  // refused instead of answered with the raster's bounds.
8538
8724
  const masked = app.imageTools.applyMask(raster, ${JSON.stringify(mask)});
8539
8725
  if (!masked) {
8540
- return { success: false, itemId: itemId, error: 'the ${mask} mask was not applied — the image may not be a raster yet. The unmasked image is on the canvas as ' + itemId + '.' };
8726
+ return { success: false, itemId: itemId, error: 'the ' + ${jsLiteral(mask)} + ' mask was not applied — the image may not be a raster yet. The unmasked image is on the canvas as ' + itemId + '.' };
8541
8727
  }
8542
8728
  if (app.itemRegistry && typeof app.itemRegistry.rebind === 'function') app.itemRegistry.rebind(itemId, masked);
8543
8729
  placed = masked;
@@ -8564,7 +8750,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8564
8750
  success: true,
8565
8751
  itemId: itemId,
8566
8752
  message: 'Image imported and placed on canvas.',${name ? `\n name: ${JSON.stringify(name)},` : ''}
8567
- bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: '${mask}'` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
8753
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height }${mask ? `,\n mask: ${jsLiteral(mask)}` : ''}${hasShadow ? ',\n shadow: __shadow' : ''},
8568
8754
  // Stored smaller than its source (the studio's cap): said, with the
8569
8755
  // way to keep every pixel. The studio reports native size where it can.
8570
8756
  ...((entry && entry.nativeWidth && entry.width && entry.nativeWidth > entry.width)
@@ -8658,7 +8844,7 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8658
8844
  (function() {
8659
8845
  ${guard}
8660
8846
  const scene = app.sceneManager.getScene(${JSON.stringify(input.sceneId)});
8661
- if (!scene) return { error: 'Scene not found: ${input.sceneId}' };
8847
+ if (!scene) return { error: 'Scene not found: ' + ${jsLiteral(input.sceneId)} };
8662
8848
  return { success: true, action: 'info', scene: scene };
8663
8849
  })();
8664
8850
  `.trim();
@@ -9122,7 +9308,7 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
9122
9308
  selected.push(id);
9123
9309
  }
9124
9310
  }
9125
- return { success: true, action: 'select', mode: '${mode}', selected };
9311
+ return { success: true, action: 'select', mode: ${jsLiteral(mode)}, selected };
9126
9312
  })();`.trim();
9127
9313
  }
9128
9314
  case 'select_all':
@@ -9179,7 +9365,7 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
9179
9365
  */
9180
9366
  _facadeCall(method, args, label) {
9181
9367
  return `
9182
- // ${label}
9368
+ // ${oneLine(label)}
9183
9369
  (async function() {
9184
9370
  if (typeof app.${method} !== 'function') {
9185
9371
  return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
@@ -9201,7 +9387,7 @@ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.s
9201
9387
  // studios, and a brand must not cross between them.
9202
9388
  if (input.action === 'get' || input.action === 'clear') {
9203
9389
  return `
9204
- // Brand kit: ${input.action} the active brand
9390
+ // Brand kit: ${oneLine(input.action)} the active brand
9205
9391
  (function() {
9206
9392
  const b = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
9207
9393
  ${input.action === 'clear' ? 'if (typeof window !== "undefined") window.__ppActiveBrand = null;' : ''}
@@ -9458,7 +9644,7 @@ ${loads}
9458
9644
  __src = raster.url;
9459
9645
  }`;
9460
9646
  const call = (method, args, label) => `
9461
- // ${label}
9647
+ // ${oneLine(label)}
9462
9648
  (async function() {
9463
9649
  if (typeof app.${method} !== 'function') {
9464
9650
  return { success: false, error: 'app.${method}() unavailable — update PinePaper Studio to a build that has it.' };
@@ -9602,7 +9788,7 @@ ${resolveSource}
9602
9788
  case 'fit': {
9603
9789
  const mode = input.mode || 'contain';
9604
9790
  return `
9605
- // Fit item to the export frame (${mode})
9791
+ // Fit item to the export frame (${oneLine(mode)})
9606
9792
  (function() {
9607
9793
  // Route through the agent facade, not app.fitToFrame directly: an agent that
9608
9794
  // just called upload_video holds a MEDIA id (vraster_…), which is a different
@@ -9645,7 +9831,7 @@ ${resolveSource}
9645
9831
  case 'flip': {
9646
9832
  const dir = input.direction || 'horizontal';
9647
9833
  return `
9648
- // Flip item ${dir}ly
9834
+ // Flip item ${oneLine(dir)}ly
9649
9835
  (function() {
9650
9836
  const entry = app.itemRegistry.get(${ID});
9651
9837
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
@@ -9666,7 +9852,7 @@ ${resolveSource}
9666
9852
  if (needsSibling) {
9667
9853
  const siblingExpr = order === 'moveUp' ? 'entry.item.nextSibling' : 'entry.item.previousSibling';
9668
9854
  return `
9669
- // Reorder item: ${order}
9855
+ // Reorder item: ${oneLine(order)}
9670
9856
  (function() {
9671
9857
  const entry = app.itemRegistry.get(${ID});
9672
9858
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
@@ -9676,7 +9862,7 @@ ${resolveSource}
9676
9862
  })();`.trim();
9677
9863
  }
9678
9864
  return `
9679
- // Reorder item: ${order}
9865
+ // Reorder item: ${oneLine(order)}
9680
9866
  (function() {
9681
9867
  const entry = app.itemRegistry.get(${ID});
9682
9868
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${ID} };
@@ -9766,7 +9952,7 @@ ${resolve}
9766
9952
  // otherwise; a falsy answer is a refusal it did not raise.
9767
9953
  if (r === false || r === null) {
9768
9954
  return { success: false, action: 'apply', itemId: ${JSON.stringify(input.itemId)},
9769
- error: 'the filter did not apply — ${input.filterName || ''} may not be available on this studio.' };
9955
+ error: 'the filter did not apply — ' + ${jsLiteral(input.filterName || '')} + ' may not be available on this studio.' };
9770
9956
  }
9771
9957
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
9772
9958
  } catch (e) {
@@ -10121,7 +10307,7 @@ ${resolve}
10121
10307
  // Get item details by ID
10122
10308
  (function() {
10123
10309
  const entry = app.itemRegistry.get(${JSON.stringify(input.itemId || '')});
10124
- if (!entry || !entry.item) return { error: 'Item not found: ${input.itemId}' };
10310
+ if (!entry || !entry.item) return { error: 'Item not found: ' + ${jsLiteral(input.itemId)} };
10125
10311
  const item = entry.item;
10126
10312
  return {
10127
10313
  success: true, action: 'get_by_id',
@@ -10421,8 +10607,8 @@ ${resolve}
10421
10607
  ${guard}
10422
10608
  const entry = app.itemRegistry.get(${itemIdStr});
10423
10609
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
10424
- app.deformPresets.apply(entry.item, '${input.preset}', ${JSON.stringify(params)});
10425
- return { success: true, action: 'apply', itemId: ${itemIdStr}, preset: '${input.preset}' };
10610
+ app.deformPresets.apply(entry.item, ${jsLiteral(input.preset)}, ${JSON.stringify(params)});
10611
+ return { success: true, action: 'apply', itemId: ${itemIdStr}, preset: ${jsLiteral(input.preset)} };
10426
10612
  })();`.trim();
10427
10613
  }
10428
10614
  case 'trigger': {
@@ -10442,8 +10628,8 @@ ${resolve}
10442
10628
  ${guard}
10443
10629
  const entry = app.itemRegistry.get(${itemIdStr});
10444
10630
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
10445
- app.deformPresets.trigger(entry.item, '${input.preset}', ${JSON.stringify(params)});
10446
- return { success: true, action: 'trigger', itemId: ${itemIdStr}, preset: '${input.preset}' };
10631
+ app.deformPresets.trigger(entry.item, ${jsLiteral(input.preset)}, ${JSON.stringify(params)});
10632
+ return { success: true, action: 'trigger', itemId: ${itemIdStr}, preset: ${jsLiteral(input.preset)} };
10447
10633
  })();`.trim();
10448
10634
  }
10449
10635
  case 'remove': {
@@ -10696,8 +10882,8 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10696
10882
  // Export sprite sheet
10697
10883
  (async function() {
10698
10884
  ${guard}
10699
- const result = await app.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: '${input.format || 'png'}', download: true, includeMetadata: true });
10700
- return { success: true, action: 'export', format: '${input.format || 'png'}' };
10885
+ const result = await app.exportSpriteSheet(${JSON.stringify(input.spriteSheetId || '')}, { format: ${jsLiteral(input.format || 'png')}, download: true, includeMetadata: true });
10886
+ return { success: true, action: 'export', format: ${jsLiteral(input.format || 'png')} };
10701
10887
  })();`.trim();
10702
10888
  default:
10703
10889
  return `(function() { return { error: 'Unknown sprite sheet action: ${input.action}' }; })();`;
@@ -10718,8 +10904,8 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10718
10904
  ${guard}
10719
10905
  const entry = app.itemRegistry.get(${itemIdStr});
10720
10906
  if (!entry || !entry.item) return { error: 'Item not found: ' + ${itemIdStr} };
10721
- const behaviorId = app.interactionSystem.addContinuousBehavior(entry.item, '${input.behaviorType}', ${params});
10722
- return { success: true, action: 'add_behavior', itemId: ${itemIdStr}, behaviorType: '${input.behaviorType}', behaviorId };
10907
+ const behaviorId = app.interactionSystem.addContinuousBehavior(entry.item, ${jsLiteral(input.behaviorType)}, ${params});
10908
+ return { success: true, action: 'add_behavior', itemId: ${itemIdStr}, behaviorType: ${jsLiteral(input.behaviorType)}, behaviorId };
10723
10909
  })();`.trim();
10724
10910
  }
10725
10911
  case 'remove_behavior': {
@@ -10776,8 +10962,8 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
10776
10962
  // Trigger interaction action
10777
10963
  (function() {
10778
10964
  ${guard}
10779
- app.interactionSystem.executeAction('${input.actionType}', ${params});
10780
- return { success: true, action: 'trigger_action', actionType: '${input.actionType}' };
10965
+ app.interactionSystem.executeAction(${jsLiteral(input.actionType)}, ${params});
10966
+ return { success: true, action: 'trigger_action', actionType: ${jsLiteral(input.actionType)} };
10781
10967
  })();`.trim();
10782
10968
  }
10783
10969
  case 'get_state':
@@ -11204,14 +11390,14 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11204
11390
  }[input.action];
11205
11391
  const id = JSON.stringify(input.itemId);
11206
11392
  return `
11207
- // Arrange ${input.itemId} → ${input.action}
11393
+ // Arrange ${oneLine(input.itemId)} → ${oneLine(input.action)}
11208
11394
  (function() {
11209
11395
  const item = app.getItemById(${id});
11210
11396
  if (!item) return { success: false, error: 'Item not found: ' + ${id} };
11211
11397
  if (typeof app.${method} !== 'function') return { success: false, error: 'app.${method} unavailable — update FxTool' };
11212
11398
  app.select(${id});
11213
11399
  app.${method}();
11214
- return { success: true, itemId: ${id}, action: '${input.action}' };
11400
+ return { success: true, itemId: ${id}, action: ${jsLiteral(input.action)} };
11215
11401
  })();`.trim();
11216
11402
  }
11217
11403
  /**
@@ -11244,7 +11430,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11244
11430
  ...(input.queries !== undefined ? { queries: input.queries } : {}),
11245
11431
  });
11246
11432
  return `
11247
- // Detect objects${input.queries !== undefined ? ' (open-vocabulary / OWL-ViT)' : ''}${input.asNodes ? ' (asNodes — relational compositing)' : ''}
11433
+ // Detect objects${oneLine(input.queries !== undefined ? ' (open-vocabulary / OWL-ViT)' : '')}${oneLine(input.asNodes ? ' (asNodes — relational compositing)' : '')}
11248
11434
  (async function() {
11249
11435
  if (typeof app.detectObjects !== 'function') {
11250
11436
  return { success: false, error: 'app.detectObjects unavailable — update FxTool to a build that exposes on-device object detection on app' };
@@ -11274,7 +11460,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11274
11460
  ...(input.id !== undefined ? { id: input.id } : {}),
11275
11461
  };
11276
11462
  return `
11277
- // Styled scene${input.style ? ` (${input.style.replace(/[\r\n]/g, ' ')})` : ''}
11463
+ // Styled scene${input.style ? ` (${oneLine(input.style.replace(/[\r\n]/g, ' '))})` : ''}
11278
11464
  (async function() {
11279
11465
  if (typeof app.styledScene !== 'function') { return { success: false, error: 'app.styledScene unavailable — update FxTool' }; }
11280
11466
  const r = await app.styledScene(${JSON.stringify(input.spec ?? {})}, ${JSON.stringify(opts)});
@@ -11294,7 +11480,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11294
11480
  ...(input.threshold !== undefined ? { threshold: input.threshold } : {}),
11295
11481
  });
11296
11482
  return `
11297
- // Extract object${input.label ? ` "${input.label}"` : ''} from the image
11483
+ // Extract object${input.label ? ` ${jsLiteral(input.label)}` : ''} from the image
11298
11484
  (async function() {
11299
11485
  if (typeof app.extractObject !== 'function') {
11300
11486
  return { success: false, error: 'app.extractObject unavailable — update FxTool to a build that exposes on-device object detection on app' };
@@ -11384,7 +11570,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11384
11570
  if (input.action === 'break_apart') {
11385
11571
  const idExpr = JSON.stringify(input.itemId || '');
11386
11572
  return `
11387
- // Break apart ${input.itemId || ''} into movable parts
11573
+ // Break apart ${oneLine(input.itemId || '')} into movable parts
11388
11574
  (function() {
11389
11575
  if (typeof app.breakApart !== 'function') return { success: false, error: 'app.breakApart unavailable — update FxTool to a build with SVG/group segmentation' };
11390
11576
  const target = app.getItemById(${idExpr});
@@ -11399,7 +11585,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11399
11585
  if (input.action === 'ungroup') {
11400
11586
  const idExpr = JSON.stringify(input.groupId || '');
11401
11587
  return `
11402
- // Ungroup ${input.groupId || ''}
11588
+ // Ungroup ${oneLine(input.groupId || '')}
11403
11589
  (function() {
11404
11590
  if (!app.groupManager) return { success: false, error: 'GroupManager not available — update FxTool' };
11405
11591
  const gid = ${idExpr};
@@ -11413,7 +11599,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11413
11599
  const idsArrayExpr = JSON.stringify(input.itemIds ?? []);
11414
11600
  const nameExpr = input.groupName !== undefined ? JSON.stringify(input.groupName) : 'undefined';
11415
11601
  return `
11416
- // Group ${(input.itemIds || []).length} items${input.groupName ? ` as "${input.groupName}"` : ''}
11602
+ // Group ${oneLine((input.itemIds || []).length)} items${input.groupName ? ` as ${jsLiteral(input.groupName)}` : ''}
11417
11603
  (function() {${this.groupCodeBody(idsArrayExpr, nameExpr)}
11418
11604
  })();`.trim();
11419
11605
  }
@@ -11463,7 +11649,7 @@ if (!app.spriteSystem) return { error: 'SpriteSheetSystem not available' };`;
11463
11649
  const duration = input.duration ?? 5;
11464
11650
  const n = Math.min(1200, Math.max(3, Math.round(duration * fps)));
11465
11651
  return `
11466
- // Accessibility measurements (${checks.join(', ')})
11652
+ // Accessibility measurements (${oneLine(checks.join(', '))})
11467
11653
  (async function() {
11468
11654
  const out = { success: true, fps: ${fps}, duration: ${duration} };
11469
11655
  const rgba = function(c) {
@@ -11620,7 +11806,7 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11620
11806
  const mime = sheet.format === 'png' ? 'image/png' : 'image/jpeg';
11621
11807
  const S = (v) => JSON.stringify(v);
11622
11808
  return `
11623
- // Contact sheet of ${n} frame(s)
11809
+ // Contact sheet of ${oneLine(n)} frame(s)
11624
11810
  (async function() {
11625
11811
  if (typeof app.captureFramesAt !== 'function') {
11626
11812
  return { success: false, error: 'app.captureFramesAt unavailable — update FxTool to a build with the deterministic capture entrypoint' };
@@ -11696,7 +11882,7 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11696
11882
  // tool call that screenshots next wants that to have happened.
11697
11883
  const engineOptsJson = JSON.stringify({ canvas: input.canvas, defaultGeometry: input.defaultGeometry, awaitImages: true });
11698
11884
  return `
11699
- // Instantiate ontology → scene (${ops.filter((o) => o.op === 'create').length} items, ${ops.filter((o) => o.op === 'addRelation').length} relations)
11885
+ // Instantiate ontology → scene (${oneLine(ops.filter((o) => o.op === 'create').length)} items, ${oneLine(ops.filter((o) => o.op === 'addRelation').length)} relations)
11700
11886
  (async function() {
11701
11887
  // Engine door first: the studio's own OntologyCompiler reads every node facet
11702
11888
  // (geometry, paint, text, the keyframe track, masks) and builds through the
@@ -11781,10 +11967,27 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11781
11967
  return { success: false, error: 'app.lintScene unavailable — update FxTool to a build with the relational linter (S12-E2)' };
11782
11968
  }
11783
11969
  const result = app.lintScene(${optsJson});
11970
+ // THE RELATIONS AS BOUND, so a later agent can find the one to edit without exporting the scene to read it:
11971
+ // from, to, type and params, capped. And the custom relation definitions registered on this page, as given.
11972
+ const __rels = [];
11973
+ let __relCount = 0;
11974
+ try {
11975
+ const __rr = app.relationRegistry;
11976
+ const __active = __rr && typeof __rr.queryActive === 'function' ? __rr.queryActive() : [];
11977
+ __active.forEach((e) => (e.associations || []).forEach((a) => {
11978
+ __relCount++;
11979
+ if (__rels.length >= ${RELATIONS_READBACK_CAP}) return;
11980
+ const __name = e.item && e.item.data && e.item.data.name;
11981
+ __rels.push({ from: e.itemId, ...(__name ? { fromName: __name } : {}), to: a.target === undefined ? null : a.target, type: a.relation, params: a.params || {} });
11982
+ }));
11983
+ } catch (e) { /* the relations are an addition; the lint stands without them */ }
11984
+ const __custom = (typeof window !== 'undefined' && window.__ppCustomRelations) ? Object.values(window.__ppCustomRelations) : [];
11784
11985
  // warnings: what the engine's own rules found (TEXT_CLIPS_FRAME: a centre-anchored text running past the
11785
11986
  // frame edge). Passed through when the studio reports them; an older studio has none to pass.
11786
11987
  return { success: true, density: result.density, suggestions: result.suggestions,
11787
- ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}) };
11988
+ ...(Array.isArray(result.warnings) && result.warnings.length ? { warnings: result.warnings } : {}),
11989
+ relations: __rels, ...(__relCount > __rels.length ? { relationsShown: __rels.length, relationCount: __relCount } : {}),
11990
+ ...(__custom.length ? { customRelations: __custom } : {}) };
11788
11991
  })();`.trim();
11789
11992
  }
11790
11993
  /**
@@ -11815,11 +12018,11 @@ ${checks.includes('contrast') ? ` // SAMPLED OVER TIME (round 12, 12.9): text i
11815
12018
  if (u.indexOf('data:') !== 0) return u;
11816
12019
  const comma = u.indexOf(',');
11817
12020
  const meta = u.slice(5, comma);
11818
- const mime = meta.split(';')[0] || '${kind === 'audio' ? 'audio/mpeg' : 'video/mp4'}';
12021
+ const mime = meta.split(';')[0] || ${jsLiteral(kind === 'audio' ? 'audio/mpeg' : 'video/mp4')};
11819
12022
  const body = u.slice(comma + 1);
11820
12023
  const bin = /;base64/i.test(meta) ? atob(body) : decodeURIComponent(body);
11821
12024
  const bytes = Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
11822
- const ext = (mime.split('/')[1] || '${kind === 'audio' ? 'mp3' : 'mp4'}').replace(/[^a-z0-9]/gi, '');
12025
+ const ext = (mime.split('/')[1] || ${jsLiteral(kind === 'audio' ? 'mp3' : 'mp4')}).replace(/[^a-z0-9]/gi, '');
11823
12026
  return new File([bytes], '${kind}.' + ext, { type: mime });
11824
12027
  })(${JSON.stringify(url ?? '')})`;
11825
12028
  // TWO ID SPACES, AND THE CALLER HOLDS THE WRONG ONE.
@@ -11928,7 +12131,7 @@ ${input.timeOffset !== undefined ? ` // Moving the clip is the 4th argument, wh
11928
12131
  // refusals come back as {ok:false, reason} and are errors here.
11929
12132
  case 'split':
11930
12133
  return `
11931
- // Split a media clip at canvas time ${input.at}
12134
+ // Split a media clip at canvas time ${oneLine(input.at)}
11932
12135
  (async function() {
11933
12136
  ${guard}
11934
12137
  if (typeof A.splitMedia !== 'function') { return { success: false, action: 'split', error: 'this studio is too old to split a clip (no splitMedia) — nothing was changed.' }; }
@@ -11938,7 +12141,7 @@ ${guard}
11938
12141
  })();`.trim();
11939
12142
  case 'add_transition':
11940
12143
  return `
11941
- // ${input.transition?.type ?? 'crossfade'} between ${input.fromItemId} and ${input.toItemId}
12144
+ // ${oneLine(input.transition?.type ?? 'crossfade')} between ${oneLine(input.fromItemId)} and ${oneLine(input.toItemId)}
11942
12145
  (function() {
11943
12146
  ${guard}
11944
12147
  if (typeof A.addTransition !== 'function') { return { success: false, action: 'add_transition', error: 'this studio is too old for clip transitions (no addTransition) — nothing was changed.' }; }
@@ -12060,7 +12263,7 @@ ${guard}
12060
12263
  ...(input.live !== undefined ? { live: input.live } : {}),
12061
12264
  });
12062
12265
  return `
12063
- // Track matte: the matte item's ${input.channel || 'luma'} drives the target's alpha${input.live ? ' (LIVE — re-cuts as the matte animates)' : ''}
12266
+ // Track matte: the matte item's ${oneLine(input.channel || 'luma')} drives the target's alpha${oneLine(input.live ? ' (LIVE — re-cuts as the matte animates)' : '')}
12064
12267
  (async function() {
12065
12268
  // A media id (vraster_ / araster_, what upload returns) is resolved to the
12066
12269
  // registry id these app facades take (round 10, 10.12).
@@ -12145,7 +12348,7 @@ ${guard}
12145
12348
  ...(input.fontSize !== undefined ? { fontSize: input.fontSize } : {}),
12146
12349
  });
12147
12350
  return `
12148
- // Display text style: ${input.styleKey} — stacked-layer title, id adopted from the text
12351
+ // Display text style: ${oneLine(input.styleKey)} — stacked-layer title, id adopted from the text
12149
12352
  (async function() {
12150
12353
  if (typeof app.applyTextStyle !== 'function') { return { success: false, error: 'app.applyTextStyle unavailable — update FxTool to a text-styles build' }; }
12151
12354
  // LOAD THE FACES FIRST. The decorative stylesheet is fetched on pointerenter
@@ -12296,7 +12499,7 @@ ${guard}
12296
12499
  const S = (v) => JSON.stringify(v);
12297
12500
  const id = S(input.itemId);
12298
12501
  return `
12299
- // Choreograph ${input.beats.length} beat(s) for ${input.itemId}
12502
+ // Choreograph ${oneLine(input.beats.length)} beat(s) for ${oneLine(input.itemId)}
12300
12503
  (function() {
12301
12504
  const __it = (app.getItemById && app.getItemById(${id}))
12302
12505
  || (app.itemRegistry && app.itemRegistry.get && (app.itemRegistry.get(${id}) || {}).item);
@@ -12404,7 +12607,7 @@ ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the
12404
12607
  const cam = input.camera === true ? { fill: 0.45 } : (input.camera || null);
12405
12608
  const sound = input.sound === true ? 'whoosh' : (input.sound || null);
12406
12609
  return `
12407
- // Morph sequence: ${states.length} state(s)
12610
+ // Morph sequence: ${oneLine(states.length)} state(s)
12408
12611
  (function() {
12409
12612
  const src = app.getItemById && app.getItemById(${S(input.itemId)});
12410
12613
  if (!src) { return { success: false, error: ${S(input.itemId)} + ' is not on the canvas.' }; }
@@ -12881,7 +13084,7 @@ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph
12881
13084
  }
12882
13085
  })();
12883
13086
  return `
12884
- // Shader graph: ${input.action}
13087
+ // Shader graph: ${oneLine(input.action)}
12885
13088
  (async function() {
12886
13089
  ${head}
12887
13090
  ${body}
@@ -12902,7 +13105,7 @@ ${body}
12902
13105
  // or brush-only medium with the reason. The Paper item it returns is not
12903
13106
  // passed back (it cannot cross the bridge); its id is.
12904
13107
  return `
12905
- // Medium: apply ${input.medium}
13108
+ // Medium: apply ${oneLine(input.medium)}
12906
13109
  (async function() {
12907
13110
  if (typeof app.applyMedium !== 'function') { return { success: false, error: 'app.applyMedium unavailable — update FxTool' }; }
12908
13111
  const r = await app.applyMedium(${S(input.itemId)}, ${S(input.medium)}, ${S(input.options ?? {})});
@@ -13096,7 +13299,7 @@ ${body}
13096
13299
  ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
13097
13300
  });
13098
13301
  return `
13099
- // Text effect: ${input.effect} — the text becomes one animated item PER CHARACTER
13302
+ // Text effect: ${oneLine(input.effect)} — the text becomes one animated item PER CHARACTER
13100
13303
  (async function() {
13101
13304
  if (typeof app.applyTextEffect !== 'function') { return { success: false, error: 'app.applyTextEffect unavailable — update FxTool to a text-effects build' }; }
13102
13305
  const r = await app.applyTextEffect(${S(input.itemId)}, ${S(input.effect)}, ${opts});
@@ -13168,7 +13371,7 @@ ${body}
13168
13371
  ...(input.keepSource !== undefined ? { keepSource: input.keepSource } : {}),
13169
13372
  });
13170
13373
  return `
13171
- // Shatter ${input.itemId} into tiles (inert until animated — that is the point)
13374
+ // Shatter ${oneLine(input.itemId)} into tiles (inert until animated — that is the point)
13172
13375
  (function() {
13173
13376
  if (typeof app.shatterImage !== 'function') { return { success: false, error: 'app.shatterImage unavailable — update FxTool to a shatter-capable build' }; }
13174
13377
  const r = app.shatterImage(${S(input.itemId)}, ${opts});
@@ -13303,7 +13506,10 @@ ${body}
13303
13506
  const needWorld = ` if (!app._world3d) { return { success: false, error: "no 3D world — call pinepaper_world3d create first" }; }`;
13304
13507
  switch (input.action) {
13305
13508
  case 'create': {
13306
- const opts = S({ ...(input.character !== undefined ? { character: input.character } : {}) });
13509
+ if (input.control === true && input.character === false) {
13510
+ return `({ success: false, error: ${jsLiteral('control: true walks the character, and character: false leaves it out. Nothing was made. Drop one of the two.')} });`;
13511
+ }
13512
+ const opts = S({ ...(input.character !== undefined ? { character: input.character } : {}), ...(input.control === true ? { control: true } : {}) });
13307
13513
  return `
13308
13514
  // World3D: create — terrain, sky, shadows, walkable character, under the Paper canvas
13309
13515
  (async function() {
@@ -13316,7 +13522,11 @@ ${body}
13316
13522
  // params is WORLD_SCHEMA: an ARRAY of {path, kind, doc}. Object.keys on it
13317
13523
  // returned "0", "1", "2"… — the indexes, not a single usable key.
13318
13524
  const keys = !d ? [] : Array.isArray(d.params) ? d.params.map(function (e) { return e && e.path; }).filter(Boolean) : Object.keys(d.params || d);
13319
- return { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: keys };
13525
+ // What the world reports, not what was asked: an engine without the option takes the world and ignores control.
13526
+ const result = { success: true, action: 'create', spec: ${S(input.spec ?? 'forest')}, configurableKeys: keys };${input.control === true ? `
13527
+ result.control = !!(app._world3d && app._world3d.controlEnabled);
13528
+ if (!result.control) result.warning = 'control was asked for and this studio did not turn it on: the character cannot be walked with the keyboard. Update PinePaper Studio.';` : ''}
13529
+ return result;
13320
13530
  })();`.trim();
13321
13531
  }
13322
13532
  case 'describe':
@@ -13346,7 +13556,7 @@ ${needWorld}
13346
13556
  ...(input.live !== undefined ? { live: input.live } : {}),
13347
13557
  });
13348
13558
  return `
13349
- // World3D: put an actor on the stage${input.live ? ' (live sprite — a rigged character PERFORMS, not a photograph of itself)' : ''}
13559
+ // World3D: put an actor on the stage${oneLine(input.live ? ' (live sprite — a rigged character PERFORMS, not a photograph of itself)' : '')}
13350
13560
  (function() {
13351
13561
  if (typeof app.addWorldActor !== 'function') { return { success: false, error: 'app.addWorldActor unavailable — update FxTool' }; }
13352
13562
  ${needWorld}
@@ -13449,7 +13659,7 @@ ${needWorld}
13449
13659
  mesh.uniforms = uniforms;
13450
13660
  }
13451
13661
  return `
13452
- // World3D: ${input.action} — a canvas path becomes real geometry
13662
+ // World3D: ${oneLine(input.action)} — a canvas path becomes real geometry
13453
13663
  (async function() {
13454
13664
  // @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
13455
13665
  if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
@@ -13556,7 +13766,7 @@ ${needWorld}
13556
13766
  case 'import_gltf': {
13557
13767
  const fn = input.action === 'import_obj' ? 'importOBJToWorld' : 'importGLTFToWorld';
13558
13768
  return `
13559
- // World3D: ${input.action}
13769
+ // World3D: ${oneLine(input.action)}
13560
13770
  (async function() {
13561
13771
  // @engine-methods extrudeToMesh latheToMesh importOBJToWorld importGLTFToWorld
13562
13772
  if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool to a world3d-capable build' }; }
@@ -13758,7 +13968,7 @@ ${needWorld}
13758
13968
  generateDesignCompose(scene, ops, style, sizeWarning, report = {}) {
13759
13969
  const S = (v) => JSON.stringify(v);
13760
13970
  return `
13761
- // Design: compose a ${style} scene — ${ops.length} items, laid out server-side
13971
+ // Design: compose a ${oneLine(style)} scene — ${oneLine(ops.length)} items, laid out server-side
13762
13972
  (function() {
13763
13973
  if (typeof app.create !== 'function') { return { success: false, error: 'app.create unavailable — update FxTool' }; }
13764
13974
  const size = ${S({ width: scene.width, height: scene.height })};
@@ -13838,7 +14048,7 @@ ${needWorld}
13838
14048
  }
13839
14049
  const fn = action === 'figure' ? 'stickFigure' : 'stickSet';
13840
14050
  return `
13841
- // Stick: ${action}
14051
+ // Stick: ${oneLine(action)}
13842
14052
  (async function() {
13843
14053
  // @engine-methods stickFigure stickSet
13844
14054
  if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool (the stick kit is vendored from mcp-cloud)' }; }
@@ -13932,7 +14142,7 @@ ${needWorld}
13932
14142
  case 'export_dotlottie': {
13933
14143
  const fn = input.action === 'export_lottie' ? 'exportLottie' : 'exportDotLottie';
13934
14144
  return `
13935
- // Interchange: ${fn}
14145
+ // Interchange: ${oneLine(fn)}
13936
14146
  (async function() {
13937
14147
  ${guard(fn)}
13938
14148
  ${TO_DATA_URL_JS}
@@ -14049,7 +14259,7 @@ ${guard('exportPNGSequence')}
14049
14259
  const S = (v) => JSON.stringify(v);
14050
14260
  const guard = (fn) => ` if (typeof app.${fn} !== 'function') { return { success: false, error: 'app.${fn} unavailable — update FxTool' }; }`;
14051
14261
  const simple = (fn, args, key, label) => key === 'played' ? `
14052
- // Sound: ${label}
14262
+ // Sound: ${oneLine(label)}
14053
14263
  (function() {
14054
14264
  ${guard(fn)}
14055
14265
  // A play that returns no voice played nothing, and reported success (gate
@@ -14058,7 +14268,7 @@ ${guard(fn)}
14058
14268
  if (__v == null) { return { success: false, error: 'nothing played: the studio returned no voice (it may have no audio output). Placed sounds (create, sequence) and render_soundtrack work without one.' }; }
14059
14269
  return { success: true, played: __v };
14060
14270
  })();`.trim() : `
14061
- // Sound: ${label}
14271
+ // Sound: ${oneLine(label)}
14062
14272
  (function() {
14063
14273
  ${guard(fn)}
14064
14274
  return { success: true, ${key}: app.${fn}(${args}) };
@@ -14080,7 +14290,7 @@ ${guard(fn)}
14080
14290
  : simple('playSoundFromText', S(input.text), 'played', 'a described sound');
14081
14291
  case 'from_text':
14082
14292
  return `
14083
- // Sound: resolve "${input.text}" to a spec WITHOUT playing it
14293
+ // Sound: resolve ${jsLiteral(input.text)} to a spec WITHOUT playing it
14084
14294
  (function() {
14085
14295
  ${guard('buildSoundFromText')}
14086
14296
  const spec = app.buildSoundFromText(${S(input.text)});
@@ -14123,7 +14333,7 @@ ${guard('buildSoundFromText')}
14123
14333
  // checking the method a caller needs. It extracted only
14124
14334
  // renderSoundtrackWav until this line existed.
14125
14335
  return `
14126
- // Sound: define ${input.name} at runtime
14336
+ // Sound: define ${oneLine(input.name)} at runtime
14127
14337
  (function() {
14128
14338
  ${guard(fn)}
14129
14339
  var name;
@@ -14307,7 +14517,7 @@ ${guard('timbreFromPath')}
14307
14517
  opts[k] = input[k];
14308
14518
  }
14309
14519
  return `
14310
- // Motion: ${input.motion} on ${input.itemIds?.length ? `${input.itemIds.length} items` : input.itemId}
14520
+ // Motion: ${oneLine(input.motion)} on ${input.itemIds?.length ? `${oneLine(input.itemIds.length)} items` : input.itemId}
14311
14521
  (async function() {
14312
14522
  if (typeof app.animateItems !== 'function') { return { success: false, error: 'app.animateItems unavailable — update FxTool' }; }
14313
14523
  const r = await app.animateItems(${target}, ${S(opts)});
@@ -14345,7 +14555,7 @@ ${guard('timbreFromPath')}
14345
14555
  switch (input.action) {
14346
14556
  case 'boolean':
14347
14557
  return `
14348
- // Path boolean: ${input.op} — CONSUMES its operands and leaves one result
14558
+ // Path boolean: ${oneLine(input.op)} — CONSUMES its operands and leaves one result
14349
14559
  (function() {
14350
14560
  ${guard('booleanOp')}${pass(`app.booleanOp(${S(input.itemIds)}, ${S(input.op)})`)}
14351
14561
  })();`.trim();
@@ -14369,7 +14579,7 @@ ${guard('togglePathClosed')}${pass(`app.togglePathClosed(${S(input.itemId)})`)}
14369
14579
  })();`.trim();
14370
14580
  case 'pattern':
14371
14581
  return `
14372
- // Pattern: repeat the item — ${input.kind}
14582
+ // Pattern: repeat the item — ${oneLine(input.kind)}
14373
14583
  (function() {
14374
14584
  ${guard('patternFrom')}${pass(`app.patternFrom(${S(input.itemId)}, ${S(input.kind)}, ${S(input.pattern ?? {})})`)}
14375
14585
  })();`.trim();
@@ -14405,7 +14615,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14405
14615
  generateCropImage(input) {
14406
14616
  const opts = input.aspectRatio ? JSON.stringify({ aspectRatio: input.aspectRatio }) : '{}';
14407
14617
  return `
14408
- // Crop image ${input.itemId}
14618
+ // Crop image ${oneLine(input.itemId)}
14409
14619
  (async function() {
14410
14620
  if (typeof app.cropImage !== 'function') { return { success: false, error: 'app.cropImage unavailable — update FxTool to a build with one-shot image ops' }; }
14411
14621
  const result = await app.cropImage(${JSON.stringify(input.itemId)}, ${JSON.stringify(input.rect)}, ${opts});
@@ -14424,7 +14634,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14424
14634
  ...(input.smoothing !== undefined ? { smoothing: input.smoothing } : {}),
14425
14635
  });
14426
14636
  return `
14427
- // Chroma-key image ${input.itemId}
14637
+ // Chroma-key image ${oneLine(input.itemId)}
14428
14638
  (async function() {
14429
14639
  if (typeof app.applyChromaKey !== 'function') { return { success: false, error: 'app.applyChromaKey unavailable — update FxTool to a build with one-shot image ops' }; }
14430
14640
  const result = await app.applyChromaKey(${JSON.stringify(input.itemId)}, ${opts});
@@ -14840,7 +15050,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14840
15050
  const argsJson = JSON.stringify(input.args ?? []);
14841
15051
  const createAsJson = input.createAs ? JSON.stringify(input.createAs) : 'null';
14842
15052
  return `
14843
- // Geometry construction: ${input.operation}
15053
+ // Geometry construction: ${oneLine(input.operation)}
14844
15054
  (function() {
14845
15055
  const g = app.geometry;
14846
15056
  if (!g || typeof g[${op}] !== 'function') {
@@ -14902,7 +15112,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14902
15112
  const optsJson = JSON.stringify(input);
14903
15113
  const kindJson = JSON.stringify(input.kind);
14904
15114
  return `
14905
- // Equation-driven path: ${input.kind}${input.preset ? ' ' + input.preset : ''}
15115
+ // Equation-driven path: ${oneLine(input.kind)}${oneLine(input.preset ? ' ' + input.preset : '')}
14906
15116
  (function() {
14907
15117
  if (typeof app.createEquationPath !== 'function') {
14908
15118
  return { success: false, error: 'app.createEquationPath unavailable — update FxTool to a build with the Expression IR equation-path API (S10 B5)' };
@@ -14926,7 +15136,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14926
15136
  const nameJson = JSON.stringify(input.name);
14927
15137
  const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y, ...(input.at !== undefined ? { at: input.at } : {}) });
14928
15138
  return `
14929
- // Create event: ${input.name}
15139
+ // Create event: ${oneLine(input.name)}
14930
15140
  (function() {
14931
15141
  if (typeof app.createEvent !== 'function') {
14932
15142
  return { success: false, error: 'app.createEvent unavailable — update FxTool to a build with the event system (S11)' };
@@ -14941,7 +15151,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
14941
15151
  const eventIdJson = JSON.stringify(input.eventId);
14942
15152
  const payloadJson = JSON.stringify(input.payload ?? null);
14943
15153
  return `
14944
- // Pulse event: ${input.eventId}
15154
+ // Pulse event: ${oneLine(input.eventId)}
14945
15155
  (function() {
14946
15156
  if (typeof app.pulseEvent !== 'function') {
14947
15157
  return { success: false, error: 'app.pulseEvent unavailable — update FxTool to a build with the event system (S11)' };
@@ -15086,7 +15296,7 @@ return {
15086
15296
  */
15087
15297
  export function generateAutoKeyCode(itemId, props, atTime, easing) {
15088
15298
  return `
15089
- // Auto-key ${itemId} at ${atTime}s
15299
+ // Auto-key ${oneLine(itemId)} at ${oneLine(atTime)}s
15090
15300
  (function() {
15091
15301
  const item = app.getItemById(${JSON.stringify(itemId)});
15092
15302
  if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas — check the id with pinepaper_get_items.`)} };
@@ -15144,7 +15354,7 @@ export function generateAutoKeyCode(itemId, props, atTime, easing) {
15144
15354
  */
15145
15355
  export function generateQueryMutationsCode(itemId, property, range, fps = 30) {
15146
15356
  return `
15147
- // Keyframe series for ${itemId}${property ? '.' + property : ''}
15357
+ // Keyframe series for ${oneLine(itemId)}${oneLine(property ? '.' + property : '')}
15148
15358
  (function() {
15149
15359
  const item = app.getItemById(${JSON.stringify(itemId)});
15150
15360
  if (!item) return { success: false, error: ${JSON.stringify(`no item "${itemId}" on the canvas.`)} };