@pinepaper.studio/mcp-server 1.6.19 → 1.6.20

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 (79) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +86 -4
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/build-info.json +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  15. package/dist/ontology/upstream-vocabulary.js +2 -2
  16. package/dist/ontology/vocabulary.d.ts.map +1 -1
  17. package/dist/ontology/vocabulary.js +8 -0
  18. package/dist/ontology/vocabulary.js.map +1 -1
  19. package/dist/tools/choreograph.d.ts +64 -0
  20. package/dist/tools/choreograph.d.ts.map +1 -0
  21. package/dist/tools/choreograph.js +336 -0
  22. package/dist/tools/choreograph.js.map +1 -0
  23. package/dist/tools/compact-descriptions.js +1 -1
  24. package/dist/tools/definitions.d.ts +10 -1
  25. package/dist/tools/definitions.d.ts.map +1 -1
  26. package/dist/tools/definitions.js +284 -25
  27. package/dist/tools/definitions.js.map +1 -1
  28. package/dist/tools/effect-check.d.ts +26 -0
  29. package/dist/tools/effect-check.d.ts.map +1 -0
  30. package/dist/tools/effect-check.js +129 -0
  31. package/dist/tools/effect-check.js.map +1 -0
  32. package/dist/tools/effect-class.d.ts +31 -0
  33. package/dist/tools/effect-class.d.ts.map +1 -0
  34. package/dist/tools/effect-class.js +170 -0
  35. package/dist/tools/effect-class.js.map +1 -0
  36. package/dist/tools/engine-lists.d.ts +6 -6
  37. package/dist/tools/engine-lists.d.ts.map +1 -1
  38. package/dist/tools/engine-lists.js +11 -9
  39. package/dist/tools/engine-lists.js.map +1 -1
  40. package/dist/tools/engine-methods.d.ts.map +1 -1
  41. package/dist/tools/engine-methods.js +8 -0
  42. package/dist/tools/engine-methods.js.map +1 -1
  43. package/dist/tools/engine-surface.d.ts +1 -1
  44. package/dist/tools/engine-surface.d.ts.map +1 -1
  45. package/dist/tools/engine-surface.js +188 -10
  46. package/dist/tools/engine-surface.js.map +1 -1
  47. package/dist/tools/handlers/export.d.ts.map +1 -1
  48. package/dist/tools/handlers/export.js +8 -1
  49. package/dist/tools/handlers/export.js.map +1 -1
  50. package/dist/tools/handlers.d.ts +23 -0
  51. package/dist/tools/handlers.d.ts.map +1 -1
  52. package/dist/tools/handlers.js +176 -5
  53. package/dist/tools/handlers.js.map +1 -1
  54. package/dist/tools/index.d.ts +2 -2
  55. package/dist/tools/index.d.ts.map +1 -1
  56. package/dist/tools/index.js +2 -2
  57. package/dist/tools/index.js.map +1 -1
  58. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  59. package/dist/tools/minimal-descriptions.js +9 -3
  60. package/dist/tools/minimal-descriptions.js.map +1 -1
  61. package/dist/tools/shape-params.d.ts +2 -2
  62. package/dist/tools/shape-params.d.ts.map +1 -1
  63. package/dist/tools/shape-params.js +24 -2
  64. package/dist/tools/shape-params.js.map +1 -1
  65. package/dist/tools/stick-vocabulary.d.ts +1 -1
  66. package/dist/tools/stick-vocabulary.js +1 -1
  67. package/dist/tools/toolkits.d.ts.map +1 -1
  68. package/dist/tools/toolkits.js +6 -0
  69. package/dist/tools/toolkits.js.map +1 -1
  70. package/dist/types/code-generator.d.ts +35 -1
  71. package/dist/types/code-generator.d.ts.map +1 -1
  72. package/dist/types/code-generator.js +538 -24
  73. package/dist/types/code-generator.js.map +1 -1
  74. package/dist/types/schemas.d.ts +1617 -273
  75. package/dist/types/schemas.d.ts.map +1 -1
  76. package/dist/types/schemas.js +164 -2
  77. package/dist/types/schemas.js.map +1 -1
  78. package/manifest.json +25 -1
  79. package/package.json +4 -3
@@ -4,12 +4,13 @@
4
4
  * Converts MCP tool inputs to executable PinePaper JavaScript code.
5
5
  * This code is designed to run in the browser context where window.PinePaper is available.
6
6
  */
7
+ import { compileChoreography } from '../tools/choreograph.js';
7
8
  import { normalizeSvgSize } from '../utils/svg-size.js';
8
9
  import { WORLD3D_COLOR_PATHS } from '../tools/engine-lists.js';
9
10
  import { REQUIRED_ENGINE_METHODS, OPTIONAL_ENGINE_METHODS } from '../tools/engine-methods.js';
10
11
  import { ACCEPTED_CREATE_PARAMS, NORMALIZE_PARAM_READS, MODIFY_CHANGE_READS } from '../tools/shape-params.js';
11
12
  import { generateP5DrawCode } from '../tools/p5-compat/p5-helpers.js';
12
- import { CreateItemInputSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
13
+ import { CreateItemInputSchema, ScreenSpaceSchema, StepTimingSchema, CounterSchema, ImportMotionCaptureInputSchema, ModifyItemInputSchema, AddRelationInputSchema, AnimateItemInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, CreateSceneInputSchema,
13
14
  // Diagram types
14
15
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
15
16
  // Agent flow mode types
@@ -1955,6 +1956,108 @@ export function planKeyframeMerges(operations) {
1955
1956
  * Such a snippet's value is its last top-level (-led statement; the
1956
1957
  * font-loading form is an async IIFE, which is awaited instead.
1957
1958
  */
1959
+ /**
1960
+ * The active brand's defaults for a new item (D69): only what the caller did
1961
+ * not name. Closed shapes take colors.primary; text takes colors.text (or
1962
+ * primary) and the heading font from 32 px up, else the body font. Applied
1963
+ * through modifyItem after create, so the engine's own bookkeeping runs.
1964
+ */
1965
+ const BRAND_FILL_TYPES = new Set(['circle', 'star', 'rectangle', 'triangle', 'polygon', 'ellipse', 'pentagon', 'hexagon', 'diamond', 'arrow', 'heart']);
1966
+ function withBrandDefaults(code, itemType, props) {
1967
+ if (props.noBrand === true)
1968
+ return code;
1969
+ const isText = itemType === 'text';
1970
+ if (!isText && !BRAND_FILL_TYPES.has(itemType))
1971
+ return code;
1972
+ const hasColor = ['color', 'fillColor', 'fill'].some((k) => props[k] !== undefined);
1973
+ const hasFont = props.fontFamily !== undefined;
1974
+ if (hasColor && (!isText || hasFont))
1975
+ return code;
1976
+ const size = typeof props.fontSize === 'number' ? props.fontSize : 36;
1977
+ const anchor = 'const itemId = item.data.registryId;';
1978
+ if (!code.includes(anchor))
1979
+ return code;
1980
+ const patch = `
1981
+ // Active brand (D69): fill in what this call did not name.
1982
+ const __bk = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
1983
+ let __brand = null;
1984
+ if (__bk && __bk.colors && app.modifyItem) {
1985
+ const __bm = {};
1986
+ ${hasColor ? '' : `__bm.color = ${isText ? '__bk.colors.text || __bk.colors.primary' : '__bk.colors.primary'};`}
1987
+ ${isText && !hasFont ? `const __f = __bk.fonts && (${size >= 32 ? '__bk.fonts.heading || __bk.fonts.body' : '__bk.fonts.body || __bk.fonts.heading'}); if (__f) __bm.fontFamily = __f;` : ''}
1988
+ if (Object.keys(__bm).length) { app.modifyItem(itemId, __bm); __brand = __bm; }
1989
+ }`;
1990
+ return code.replace(anchor, anchor + patch).replace(/\(\{ itemId, type:/, '({ itemId, ...(__brand ? { brand: __brand } : {}), type:');
1991
+ }
1992
+ const ITEM_SETTERS = [
1993
+ { key: 'screenSpace', fn: 'setScreenSpace', what: 'screen-space (HUD) items', since: 'D20' },
1994
+ { key: 'stepTiming', fn: 'setStepTiming', what: 'step timing', since: 'D11' },
1995
+ { key: 'counter', fn: 'setCounter', what: 'counters', since: 'D70' },
1996
+ ];
1997
+ /** Lift screenSpace / hud / stepTiming out of a properties bag (mutated), explicit fields winning. */
1998
+ function takeItemSetters(props, explicit = {}) {
1999
+ const out = {};
2000
+ // From properties a caller may write hud: 1; it means on. The top-level
2001
+ // field is still a strict boolean.
2002
+ const ss = explicit.screenSpace ?? (props.screenSpace ?? props.hud);
2003
+ if (ss !== undefined)
2004
+ out.screenSpace = explicit.screenSpace !== undefined ? ScreenSpaceSchema.parse(ss) : !!ss;
2005
+ const st = explicit.stepTiming !== undefined ? explicit.stepTiming : props.stepTiming;
2006
+ if (st !== undefined)
2007
+ out.stepTiming = StepTimingSchema.parse(st);
2008
+ // A counter from properties is lifted only when it is a valid spec; anything
2009
+ // else stays where it was, for the engine to read and refuse by name.
2010
+ let liftCounter = explicit.counter !== undefined;
2011
+ if (liftCounter)
2012
+ out.counter = CounterSchema.parse(explicit.counter);
2013
+ else if (props.counter !== undefined && CounterSchema.safeParse(props.counter).success) {
2014
+ out.counter = props.counter;
2015
+ liftCounter = true;
2016
+ }
2017
+ delete props.screenSpace;
2018
+ delete props.hud;
2019
+ delete props.stepTiming;
2020
+ if (liftCounter)
2021
+ delete props.counter;
2022
+ return out;
2023
+ }
2024
+ function hasItemSetters(s) {
2025
+ return s.screenSpace !== undefined || s.stepTiming !== undefined || s.counter !== undefined;
2026
+ }
2027
+ /** Statements: refuse, before anything changes, when a requested setter is missing. */
2028
+ function itemSettersPreflightJs(s) {
2029
+ return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => ` if (typeof app.${d.fn} !== 'function') { return { success: false, error: 'this studio has no ${d.what} (app.${d.fn}) — it predates FxTool ${d.since}; nothing was changed.' }; }`).join('\n');
2030
+ }
2031
+ /** Statements: apply each setter to idExpr, folding the verdict into resVar. */
2032
+ function itemSettersApplyJs(s, idExpr, resVar) {
2033
+ return ITEM_SETTERS.filter((d) => s[d.key] !== undefined).map((d) => {
2034
+ const arg = JSON.stringify(s[d.key]);
2035
+ return ` {
2036
+ const __sr = app.${d.fn}(${idExpr}, ${arg});
2037
+ if (!__sr || __sr.ok === false) {
2038
+ return Object.assign({}, ${resVar}, { success: false, error: '${d.key} was not applied to ' + ${idExpr} + ': ' + ((__sr && (__sr.reason || __sr.error)) || 'refused by the studio') });
2039
+ }
2040
+ ${resVar}.${d.key} = ${d.key === 'screenSpace' ? arg : `(__sr.${d.key} !== undefined ? __sr.${d.key} : ${arg})`};${d.key === 'counter' ? `\n if (__sr.text !== undefined) ${resVar}.counterText = __sr.text;` : ''}
2041
+ }`;
2042
+ }).join('\n');
2043
+ }
2044
+ /** Wrap a create / modify snippet so the setters run after it, on its item. */
2045
+ function withItemSetters(code, s, idExpr) {
2046
+ if (!hasItemSetters(s))
2047
+ return code;
2048
+ return `(async function() {
2049
+ ${itemSettersPreflightJs(s)}
2050
+ const __res = await (async function() {
2051
+ ${asReturningBody(code)}
2052
+ })();
2053
+ if (!__res || __res.success === false) return __res;
2054
+ const __sid = ${idExpr};
2055
+ if (!__sid) return __res;
2056
+ ${itemSettersApplyJs(s, '__sid', '__res')}
2057
+ if (__res.success === undefined) __res.success = true;
2058
+ return __res;
2059
+ })();`;
2060
+ }
1958
2061
  function asReturningBody(snippet) {
1959
2062
  const single = snippet.trim();
1960
2063
  if (single.startsWith('(async function()'))
@@ -2398,17 +2501,27 @@ export class PinePaperCodeGenerator {
2398
2501
  properties.keyframes = validated.keyframes;
2399
2502
  if (validated.anchor !== undefined && properties.anchor === undefined && properties.origin === undefined)
2400
2503
  properties.anchor = validated.anchor;
2401
- return generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven);
2504
+ const setters = takeItemSetters(properties, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
2505
+ const brandProps = { ...properties };
2506
+ delete properties.noBrand;
2507
+ return withItemSetters(withBrandDefaults(generateCreateItemCode(validated.itemType, validated.position, properties, validated.data, positionGiven), validated.itemType, brandProps), setters, '__res.itemId');
2402
2508
  }
2403
2509
  /**
2404
2510
  * Generate code for modifying an item
2405
2511
  */
2406
2512
  generateModifyItem(input) {
2407
2513
  const validated = ModifyItemInputSchema.parse(input);
2514
+ const props = { ...validated.properties };
2515
+ const setters = takeItemSetters(props, { screenSpace: validated.screenSpace, stepTiming: validated.stepTiming, counter: validated.counter });
2516
+ const idExpr = JSON.stringify(validated.itemId);
2408
2517
  if (validated.atTime !== undefined) {
2409
- return generateAutoKeyCode(validated.itemId, validated.properties, validated.atTime, validated.easing);
2518
+ return withItemSetters(generateAutoKeyCode(validated.itemId, props, validated.atTime, validated.easing), setters, idExpr);
2519
+ }
2520
+ // Only a setter asked for: no property edit to run.
2521
+ if (hasItemSetters(setters) && Object.keys(props).length === 0 && !validated.data) {
2522
+ return withItemSetters(`({ success: true, itemId: ${idExpr} });`, setters, idExpr);
2410
2523
  }
2411
- return generateModifyItemCode(validated.itemId, validated.properties, validated.data);
2524
+ return withItemSetters(generateModifyItemCode(validated.itemId, props, validated.data), setters, idExpr);
2412
2525
  }
2413
2526
  /**
2414
2527
  * Generate code for deleting an item
@@ -2452,7 +2565,15 @@ export class PinePaperCodeGenerator {
2452
2565
  if (validated.relationType === 'moves_along_path') {
2453
2566
  return generateMovesAlongPathCode(validated.sourceId, validated.targetId ?? undefined, (validated.params ?? {}));
2454
2567
  }
2455
- return generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
2568
+ const code = generateAddRelationCode(validated.sourceId, validated.targetId || validated.sourceId, validated.relationType, validated.params);
2569
+ // NO TARGET (event reactions like on_event_play_sound, self-relations): the
2570
+ // engine is handed the source as the target, but the reply must not say
2571
+ // "item_2 → item_2" — a model reads that as the event wired to itself.
2572
+ if (validated.targetId)
2573
+ return code;
2574
+ return code
2575
+ .replace(`return { success: true, sourceId: '${validated.sourceId}', targetId: '${validated.sourceId}',`, `return { success: true, sourceId: '${validated.sourceId}', targetId: null,`)
2576
+ .replace(`relation between ${validated.sourceId} and ${validated.sourceId}.`, `relation on ${validated.sourceId} (no target).`);
2456
2577
  }
2457
2578
  /**
2458
2579
  * Generate code for removing a relation
@@ -2478,7 +2599,19 @@ export class PinePaperCodeGenerator {
2478
2599
  */
2479
2600
  generateKeyframeAnimate(input) {
2480
2601
  const validated = KeyframeAnimateInputSchema.parse(input);
2481
- return generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2602
+ // Hold keys (FxTool D11) on an engine without them would interpolate in
2603
+ // silence; refuse before anything is written. setStepTiming ships in the
2604
+ // same change, so it is the probe.
2605
+ const holds = validated.keyframes.some((k) => k.interpolation === 'hold' || k.hold === true);
2606
+ const code = generateKeyframeAnimateCode(validated.itemId, validated.keyframes, validated.duration, validated.loop, validated.timeOffset, validated.clipInPoint, validated.clipOutPoint, validated.timeUnits, validated.append);
2607
+ if (!holds)
2608
+ return code;
2609
+ return `(async function() {
2610
+ if (!(typeof app.setStepTiming === 'function')) { return { success: false, error: 'this studio has no hold keys (interpolation: \\'hold\\') — it predates FxTool D11; nothing was written. Use two keys a frame apart for a cut.' }; }
2611
+ return await (async function() {
2612
+ ${asReturningBody(code)}
2613
+ })();
2614
+ })();`;
2482
2615
  }
2483
2616
  /**
2484
2617
  * Generate code for importing / retargeting a motion-capture clip
@@ -2726,6 +2859,15 @@ throw new Error('Either svgString or url must be provided');
2726
2859
  success: !!result.success,
2727
2860
  nodeCount: nodes.length,
2728
2861
  edgeCount: edges.length,
2862
+ // CANVAS ids, the ones auto_layout / animate / add_relation take. The
2863
+ // engine's own nodeIds are the MERMAID ids (A, B…), which resolve to
2864
+ // nothing on the canvas (gate D45). Newer engines return nodeItemIds;
2865
+ // otherwise they are read off the registered nodes.
2866
+ nodeItemIds: Array.isArray(result.nodeItemIds) && result.nodeItemIds.length
2867
+ ? result.nodeItemIds
2868
+ : nodes.map((n) => n.id).filter((id) => typeof id === 'string' && /^item_/.test(id)),
2869
+ ...(result.nodeIdMap && typeof result.nodeIdMap === 'object' ? { nodeIdMap: result.nodeIdMap } : {}),
2870
+ ...(Array.isArray(result.edgeIds) && result.edgeIds.length ? { edgeIds: result.edgeIds } : {}),
2729
2871
  nodes,
2730
2872
  edges,
2731
2873
  errors: result.errors || [],
@@ -3596,6 +3738,8 @@ throw new Error('Unknown diagram mode action: ${action}');
3596
3738
  let code = `
3597
3739
  // End agent job
3598
3740
  (async function() {
3741
+ // Close the studio's job (D92). It never reloads.
3742
+ try { if (typeof window !== 'undefined' && window.PinePaperAgent && window.PinePaperAgent.endJob) window.PinePaperAgent.endJob(); } catch (_) { /* older studio */ }
3599
3743
  const result = {
3600
3744
  success: true,
3601
3745
  jobEnded: true
@@ -3900,12 +4044,19 @@ throw new Error('Unknown diagram mode action: ${action}');
3900
4044
  // the exact emitter create_item uses, adapted to return its result
3901
4045
  // inside this op's function.
3902
4046
  const pos = op.position || { x: 400, y: 300 };
3903
- const createProps = withRadiusAxes((op.properties || {}));
3904
- const returning = asReturningBody(generateCreateItemCode(op.itemType, pos, { ...(op.properties || {}) }, undefined, op.position !== undefined));
3905
- let createCode = `
4047
+ const opProps = { ...(op.properties || {}) };
4048
+ const opSetters = takeItemSetters(opProps, { screenSpace: op.screenSpace, stepTiming: op.stepTiming, counter: op.counter });
4049
+ const createProps = withRadiusAxes(opProps);
4050
+ const opBrand = { ...opProps };
4051
+ delete opProps.noBrand;
4052
+ const returning = asReturningBody(withBrandDefaults(generateCreateItemCode(op.itemType, pos, { ...opProps }, undefined, op.position !== undefined), op.itemType, opBrand));
4053
+ let createCode = `${hasItemSetters(opSetters) ? `\n${itemSettersPreflightJs(opSetters)}` : ''}
3906
4054
  const __created = await (async function() {
3907
4055
  ${returning}
3908
- })();`;
4056
+ })();${hasItemSetters(opSetters) ? `
4057
+ if (__created && __created.success !== false && __created.itemId) {
4058
+ ${itemSettersApplyJs(opSetters, '__created.itemId', '__created')}
4059
+ }` : ''}`;
3909
4060
  // Coordinate-built items (path from segments/pathData, line/arc from
3910
4061
  // from/through/to) derive their geometry from those coordinates and
3911
4062
  // IGNORE params.position — so `create` at a point silently produced an
@@ -5481,6 +5632,34 @@ ${(() => {
5481
5632
  })()}
5482
5633
  ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) { /* the still is already rendered */ }
5483
5634
  if (result && result.success) result.time = ${stillTime};` : ''}
5635
+ // SCREEN-SPACE ITEMS (FxTool D20): SVG and Lottie write geometry, not the
5636
+ // camera's frame map, so a HUD item is placed as if the camera had not moved.
5637
+ if (result && result.success && typeof app.screenSpaceItems === 'function' && ['svg', 'lottie', 'dotlottie'].indexOf(${JSON.stringify(format)}) !== -1) {
5638
+ try {
5639
+ const __hud = app.screenSpaceItems() || [];
5640
+ if (__hud.length) {
5641
+ result.fidelity = result.fidelity || { warnings: [] };
5642
+ result.fidelity.warnings = (result.fidelity.warnings || []).concat([{ code: 'screen_space_ignored', message: __hud.length + ' screen-space (HUD) item(s) are written at their frame coordinates in world space: ${format} has no camera frame, so they do not stay fixed under the camera move. Export mp4, webm, gif or png to keep them fixed.' }]);
5643
+ }
5644
+ } catch (_) { /* the export stands */ }
5645
+ }
5646
+ // WHICH STUDIO MADE THIS FILE (D92): a service worker can serve a stale
5647
+ // build right after a deploy, and a retest then judges old code. The
5648
+ // engine's own identity, version + build sha, rides on every export reply.
5649
+ try { if (result && typeof result === 'object' && app.buildId) result.studioBuild = app.buildId; } catch (_) { /* older studio */ }
5650
+ // A STALE STUDIO (D92): the file was made by an old build. Said in the
5651
+ // fidelity warnings the reply shows, not only as a field.
5652
+ try {
5653
+ const __bs = app.buildStatus ? app.buildStatus() : null;
5654
+ if (result && typeof result === 'object' && __bs) {
5655
+ result.liveBuild = __bs.liveBuildId;
5656
+ if (__bs.stale) {
5657
+ result.stale = true;
5658
+ result.fidelity = result.fidelity || { warnings: [] };
5659
+ 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.' }]);
5660
+ }
5661
+ }
5662
+ } catch (_) { /* older studio */ }
5484
5663
  // RELIGHT (FxTool D5): applied to png / pdf / mp4 / webm, not gif / apng
5485
5664
  // or the vector formats; and it is what makes a video export slow.
5486
5665
  if (result && result.success && typeof app.getRelight === 'function') {
@@ -6792,7 +6971,11 @@ ${stillTime !== undefined ? ` try { app.setPlaybackTime(__prevT); } catch (_) {
6792
6971
  if (typeof app.captureProjectDocument !== 'function') { return { success: false, error: 'app.captureProjectDocument unavailable — update FxTool' }; }
6793
6972
  ${SCENE_COUNTS_JS}
6794
6973
  const doc = app.captureProjectDocument(${JSON.stringify(name ? { name } : {})});
6795
- return { success: true, json: JSON.stringify(doc), counts: __ppSceneCounts() };
6974
+ // What the document CANNOT carry (D29): render hooks added as raw functions
6975
+ // and frame callbacks from generated code. Named, never dropped in silence.
6976
+ const __un = (doc && Array.isArray(doc.unserialized)) ? doc.unserialized
6977
+ : (app.listUnserializedCallbacks ? app.listUnserializedCallbacks() : []);
6978
+ return { success: true, json: JSON.stringify(doc), counts: __ppSceneCounts(), unserialized: __un };
6796
6979
  })();`.trim();
6797
6980
  }
6798
6981
  /** Load a staged project document (window.__ppStage.projectDoc), replacing the scene. */
@@ -7709,13 +7892,22 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
7709
7892
  opts.push(`defaultTransition: ${JSON.stringify(input.defaultTransition)}`);
7710
7893
  if (input.transitionDuration !== undefined)
7711
7894
  opts.push(`transitionDuration: ${input.transitionDuration * 1000}`);
7895
+ if (input.cutSound !== undefined)
7896
+ opts.push(`cutSound: ${JSON.stringify(input.cutSound)}`);
7712
7897
  const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : '';
7713
7898
  return `
7714
7899
  // Create scene chain for sequential playback
7715
7900
  (function() {
7716
7901
  ${guard}
7717
- app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
7718
- return { success: true, action: 'create_chain', sceneIds: ${JSON.stringify(input.sceneIds)}${input.loop !== undefined ? `, loop: ${input.loop}` : ''}${input.defaultDuration !== undefined ? `, defaultDuration: ${input.defaultDuration}` : ''} };
7902
+ const __cr = app.sceneManager.createChain(${JSON.stringify(input.sceneIds)}${optsStr});
7903
+ // An unknown cut sound comes back {ok:false, reason} (FxTool D85); a
7904
+ // studio without cut sounds ignores the option, which the cuts readback shows.
7905
+ if (__cr && __cr.ok === false) { return { success: false, action: 'create_chain', error: __cr.reason || 'the chain was refused' }; }
7906
+ const __res = { success: true, action: 'create_chain', sceneIds: ${JSON.stringify(input.sceneIds)}${input.loop !== undefined ? `, loop: ${input.loop}` : ''}${input.defaultDuration !== undefined ? `, defaultDuration: ${input.defaultDuration}` : ''} };
7907
+ ${input.cutSound !== undefined ? ` const __cuts = (__cr && __cr.cuts) || (app.sceneManager.getChainCuts ? app.sceneManager.getChainCuts() : null);
7908
+ if (__cuts) __res.cuts = __cuts;
7909
+ else __res.warning = 'this studio placed no cut sounds: it predates them (FxTool D85). The chain plays without them.';
7910
+ ` : ''} return __res;
7719
7911
  })();
7720
7912
  `.trim();
7721
7913
  }
@@ -8143,6 +8335,32 @@ ${mask ? ` // A MASK REPLACES THE RASTER (round 8 DD, 4.10). applyMask builds
8143
8335
  })();`.trim();
8144
8336
  }
8145
8337
  generateBrandKit(input) {
8338
+ // THE ACTIVE BRAND (D69). apply recolours what is on the canvas once; a
8339
+ // later beat's new items knew nothing of it. 'set' applies AND makes the
8340
+ // kit the studio's active brand, which every create reads (withBrandDefaults).
8341
+ // It lives in the PAGE, not this process: one cloud server serves many
8342
+ // studios, and a brand must not cross between them.
8343
+ if (input.action === 'get' || input.action === 'clear') {
8344
+ return `
8345
+ // Brand kit: ${input.action} the active brand
8346
+ (function() {
8347
+ const b = (typeof window !== 'undefined' && window.__ppActiveBrand) || null;
8348
+ ${input.action === 'clear' ? 'if (typeof window !== "undefined") window.__ppActiveBrand = null;' : ''}
8349
+ return { success: true, action: ${JSON.stringify(input.action)}, ${input.action === 'clear' ? 'cleared: !!b' : 'brand: b'} };
8350
+ })();`.trim();
8351
+ }
8352
+ if (input.action === 'set') {
8353
+ const applied = this.generateBrandKit({ ...input, action: 'apply' });
8354
+ return `
8355
+ // Brand kit: apply, then make it the active brand every new item inherits
8356
+ (async function() {
8357
+ const out = await ${applied.replace(/^\/\/[^\n]*\n/, '').replace(/;\s*$/, '')};
8358
+ if (out && (out.success === false || out.ok === false)) return out;
8359
+ if (typeof window !== 'undefined') window.__ppActiveBrand = ${JSON.stringify(input.kit)};
8360
+ return Object.assign({}, out, { success: true, action: 'set', active: true,
8361
+ note: 'new shapes take colors.primary and new text colors.text with the heading (32 px and up) or body font, wherever the call names none; noBrand: true opts an item out. The active brand lives with this studio session: set it again after a reload.' });
8362
+ })();`.trim();
8363
+ }
8146
8364
  const opts = JSON.stringify({ ...(input.selectionOnly ? { selectionOnly: true } : {}) });
8147
8365
  const method = input.action === 'plan' ? 'planBrandKit' : 'applyBrandKit';
8148
8366
  const facade = this._facadeCall(method, `${JSON.stringify(input.kit)}, ${opts}`, `Brand kit: ${input.action}`);
@@ -8667,8 +8885,8 @@ ${resolveSource}
8667
8885
  const resolve = ` const item = (typeof app._resolveRaster === 'function')
8668
8886
  ? app._resolveRaster(${JSON.stringify(input.itemId)})
8669
8887
  : (app.itemRegistry ? app.itemRegistry.getItem(${JSON.stringify(input.itemId)}) : null);
8670
- if (!item) return { error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
8671
- if (typeof app.applyImageFilter !== 'function') return { error: 'GPU image filters unavailable — update FxTool' };`;
8888
+ if (!item) return { success: false, error: 'Item not found or not a raster: ' + ${JSON.stringify(input.itemId)} };
8889
+ if (typeof app.applyImageFilter !== 'function') return { success: false, error: 'GPU image filters unavailable — update FxTool' };`;
8672
8890
  switch (input.action) {
8673
8891
  case 'apply':
8674
8892
  return `
@@ -8685,7 +8903,7 @@ ${resolve}
8685
8903
  }
8686
8904
  return { success: true, action: 'apply', itemId: ${JSON.stringify(input.itemId)}, filter: ${JSON.stringify(input.filterName || '')} };
8687
8905
  } catch (e) {
8688
- return { error: 'Failed to apply filter: ' + e.message };
8906
+ return { success: false, action: 'apply', error: 'Failed to apply filter: ' + e.message };
8689
8907
  }
8690
8908
  })();`.trim();
8691
8909
  case 'chain':
@@ -8698,7 +8916,7 @@ ${resolve}
8698
8916
  await app.applyImageFilterChain(item, filters);
8699
8917
  return { success: true, action: 'chain', itemId: ${JSON.stringify(input.itemId)}, filterCount: filters.length };
8700
8918
  } catch (e) {
8701
- return { error: 'Failed to apply filter chain: ' + e.message };
8919
+ return { success: false, action: 'chain', error: 'Failed to apply filter chain: ' + e.message };
8702
8920
  }
8703
8921
  })();`.trim();
8704
8922
  default:
@@ -11006,6 +11224,293 @@ ${guard}
11006
11224
  * costs ~0.42 s per 1080p frame on the CPU, so a set says what an export
11007
11225
  * will pay.
11008
11226
  */
11227
+ /**
11228
+ * pinepaper_choreograph (D67). The compiler runs IN THE STUDIO, serialised
11229
+ * by toString, because the track depends on the actor's live size, scale,
11230
+ * rotation and the export frame. Merged into the actor's track, read back
11231
+ * the way keyframe_animate reads its own.
11232
+ */
11233
+ generateChoreograph(input) {
11234
+ const S = (v) => JSON.stringify(v);
11235
+ const id = S(input.itemId);
11236
+ return `
11237
+ // Choreograph ${input.beats.length} beat(s) for ${input.itemId}
11238
+ (function() {
11239
+ const __it = (app.getItemById && app.getItemById(${id}))
11240
+ || (app.itemRegistry && app.itemRegistry.get && (app.itemRegistry.get(${id}) || {}).item);
11241
+ if (!__it || !__it.bounds) { return { success: false, error: ${id} + ' is not on the canvas — check the id with pinepaper_get_items.' }; }
11242
+ const __sc = __it.scaling || { x: 1, y: 1 };
11243
+ const __b = __it.bounds;
11244
+ const __baked = (__it.data && typeof __it.data._bakedRotation === 'number') ? __it.data._bakedRotation : 0;
11245
+ const __fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11246
+ const __frame = { x: __fr.x || 0, y: __fr.y || 0, width: __fr.width, height: __fr.height };
11247
+ const __actor = { x: __it.position.x, y: __it.position.y,
11248
+ w: __b.width / (Math.abs(__sc.x) || 1), h: __b.height / (Math.abs(__sc.y) || 1),
11249
+ sx: __sc.x || 1, sy: __sc.y || 1, rot: (__it.rotation || 0) + __baked };
11250
+ const __r = (${compileChoreography.toString()})(__actor, __frame, ${S(input.beats)});
11251
+ if (__r.error) { return { success: false, itemId: ${id}, error: __r.error }; }
11252
+ const __prev = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes : [];
11253
+ const __prevTimes = __prev.map(function (k) { return k && k.time; });
11254
+ const __ours = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'opacity'];
11255
+ const __other = __prev.reduce(function (acc, k) { Object.keys((k && k.properties) || {}).forEach(function (p) { if (__ours.indexOf(p) < 0 && acc.indexOf(p) < 0) acc.push(p); }); return acc; }, []);
11256
+ app.addAnimation(${id}, __r.keyframes, { duration: Math.max(__r.end, __prev.reduce(function (m, k) { return Math.max(m, (k && k.time) || 0); }, 0)), loop: false, mode: 'merge' });
11257
+ const __now = (__it.data && Array.isArray(__it.data.keyframes)) ? __it.data.keyframes.map(function (k) { return k && k.time; }) : [];
11258
+ const __warnings = (__r.warnings || []).slice();
11259
+ if (__prevTimes.length && !__prevTimes.every(function (t) { return __now.indexOf(t) >= 0; })) {
11260
+ __warnings.push('this studio replaced the existing track of the actor (' + __prevTimes.length + ' key(s)) instead of merging: put the other keys in after choreographing.');
11261
+ }
11262
+ if (__other.length) {
11263
+ __warnings.push('the actor already animates ' + __other.join(', ') + '; the beat keys leave those out, which the engine can break at the beat keys. Re-key ' + __other.join(', ') + ' with every key carrying them, after choreographing.');
11264
+ }
11265
+ if (__r.final && !__r.final.onScreen) __warnings.push('after the last beat the actor is off screen at [' + __r.final.x + ', ' + __r.final.y + '].');
11266
+ const __res = { success: true, itemId: ${id}, beats: ${input.beats.length}, keyframes: __r.keyframes.length, duration: __r.end, final: __r.final };
11267
+ ${input.sound ? ` // SOUND ON THE ACTION (D68): one placed sound per moment the motion makes —
11268
+ // pop, launch, contact, shake — loud as the moment is strong. A sound that
11269
+ // does not place is reported; it never undoes the motion.
11270
+ const __map = Object.assign({ pop: 'pop', whoosh: 'whoosh', contact: 'tom', shake: 'hat' }, ${S(typeof input.sound === 'object' ? Object.fromEntries(Object.entries(input.sound).filter(([k]) => k !== 'gain')) : {})});
11271
+ const __gain = ${S(typeof input.sound === 'object' && input.sound.gain !== undefined ? input.sound.gain : 0.8)};
11272
+ const __sounds = [];
11273
+ const __silent = [];
11274
+ if (typeof app.createSound !== 'function' || (typeof app.sfxSpec !== 'function' && typeof app.percussionSpec !== 'function')) {
11275
+ __warnings.push('no sound was placed: this studio cannot place synthesized sounds (app.createSound / sfxSpec). The motion is written.');
11276
+ } else {
11277
+ (__r.events || []).forEach(function (e) {
11278
+ const name = __map[e.kind];
11279
+ if (name === false || name === undefined) return;
11280
+ const base = (typeof app.sfxSpec === 'function' && app.sfxSpec(name)) || (typeof app.percussionSpec === 'function' && app.percussionSpec(name));
11281
+ if (!base) { if (__silent.indexOf(name) < 0) __silent.push(name); return; }
11282
+ const spec = Object.assign({}, base, { gain: Math.round(((base.gain || 0.8) * __gain * (0.35 + 0.65 * e.strength)) * 1000) / 1000 });
11283
+ // MP4 (AAC) encoder priming swallows the first ~50 ms of audio, and this
11284
+ // muxer writes no edit list (the engine reports audio_lead_trimmed): a pop
11285
+ // at 0 s exported as silence (prod retest of D68). 60 ms later is in sync
11286
+ // to the eye and audible in every format.
11287
+ const at = e.time < 0.06 ? 0.06 : e.time;
11288
+ const it = app.createSound(spec, { startTime: at, duration: spec.duration || 0.5 });
11289
+ if (it && it.data) __sounds.push(Object.assign({ t: at, kind: e.kind, sound: name, itemId: it.data.id }, at !== e.time ? { movedFrom: e.time } : {}));
11290
+ });
11291
+ if (__silent.length) __warnings.push('not in the sound catalogue, so silent: ' + __silent.join(', ') + ' (pinepaper_sound list_sfx / list_percussion for the names).');
11292
+ }
11293
+ __res.sounds = __sounds;
11294
+ ` : ''}
11295
+ if (__warnings.length) __res.warnings = __warnings;
11296
+ return __res;
11297
+ })();`.trim();
11298
+ }
11299
+ /**
11300
+ * pinepaper_morph_sequence (D73): ONE element through N states — R8's pill →
11301
+ * input → dot → grid → composer → orb. The engine already chains morphs_to
11302
+ * on one shape (per-relation state since gap 2.27); what a model could not
11303
+ * do was time the chain, make the reference shapes, and ride the camera on
11304
+ * the same curve. Compiled here to that chain, a camera track and sounds.
11305
+ */
11306
+ generateMorphSequence(input) {
11307
+ const S = (v) => JSON.stringify(v);
11308
+ const states = [...input.states].sort((a, b) => a.at - b.at).map((st) => ({ at: st.at, duration: st.duration ?? 0.8, to: st.to ?? null, shape: st.shape ?? null }));
11309
+ const cam = input.camera === true ? { fill: 0.45 } : (input.camera || null);
11310
+ const sound = input.sound === true ? 'whoosh' : (input.sound || null);
11311
+ return `
11312
+ // Morph sequence: ${states.length} state(s)
11313
+ (function() {
11314
+ const src = app.getItemById && app.getItemById(${S(input.itemId)});
11315
+ if (!src) { return { success: false, error: ${S(input.itemId)} + ' is not on the canvas.' }; }
11316
+ if (typeof app.addRelation !== 'function') { return { success: false, error: 'this studio cannot add relations.' }; }
11317
+ const states = ${S(states)};
11318
+ const done = [];
11319
+ for (let i = 0; i < states.length; i++) {
11320
+ const st = states[i];
11321
+ let tid = st.to;
11322
+ if (!tid) {
11323
+ const p = Object.assign({}, st.shape.properties || {}, { x: st.shape.position.x, y: st.shape.position.y });
11324
+ const made = app.create(st.shape.itemType, p);
11325
+ tid = made && made.data && made.data.registryId;
11326
+ if (!tid) { return { success: false, error: 'state ' + i + ': could not make the ' + st.shape.itemType + ' reference shape', states: done }; }
11327
+ } else if (!(app.getItemById && app.getItemById(tid))) {
11328
+ return { success: false, error: 'state ' + i + ': ' + tid + ' is not on the canvas', states: done };
11329
+ }
11330
+ const ok = app.addRelation(${S(input.itemId)}, tid, 'morphs_to', { duration: st.duration, delay: st.at, easing: ${S(input.easing ?? 'easeInOut')}, hideTarget: true });
11331
+ if (ok === false) { return { success: false, error: 'state ' + i + ': the studio refused the morph into ' + tid, states: done }; }
11332
+ done.push({ at: st.at, duration: st.duration, targetId: tid });
11333
+ }
11334
+ const res = { success: true, itemId: ${S(input.itemId)}, states: done, end: done[done.length - 1].at + done[done.length - 1].duration };
11335
+ const n = app.getRelations ? app.getRelations(${S(input.itemId)}, 'morphs_to').length : null;
11336
+ if (n !== null && n < done.length) res.warning = 'only ' + n + ' of ' + done.length + ' morphs are on the element.';
11337
+ ${cam ? ` // The camera rides the SAME timing: framing each state as its morph lands.
11338
+ {
11339
+ const fr = (typeof app.exportFrameRect === 'function' && app.exportFrameRect()) || (app.getCanvasSize && app.getCanvasSize()) || { width: 1920, height: 1080 };
11340
+ const fill = ${S(cam.fill ?? 0.45)};
11341
+ const frameOf = function (it) { const b = it.bounds; return { focus: [b.center.x, b.center.y], zoom: Math.max(0.2, Math.min(8, fill * Math.min(fr.width / Math.max(1, b.width), fr.height / Math.max(1, b.height)))) }; };
11342
+ const f0 = frameOf(src);
11343
+ const kfs = [{ time: 0, zoom: f0.zoom, focus: f0.focus }];
11344
+ done.forEach(function (d) {
11345
+ const t = app.getItemById(d.targetId);
11346
+ const f = frameOf(t);
11347
+ const prev = kfs[kfs.length - 1];
11348
+ kfs.push({ time: d.at, zoom: prev.zoom, focus: prev.focus });
11349
+ kfs.push({ time: d.at + d.duration, zoom: f.zoom, focus: f.focus, easing: ${S(cam.easing ?? 'easeInOut')} });
11350
+ });
11351
+ // The SAME door pinepaper_camera_animate uses: app.camera.animate where the
11352
+ // build has it, else the camera_animates relation — prod has only the
11353
+ // relation, and probing app.camera.animate alone reported "no camera" there.
11354
+ const r = app.camera && app.camera.animate
11355
+ ? app.camera.animate(kfs, res.end, false, 0, {})
11356
+ : app.addRelation('camera', 'camera', 'camera_animates', { keyframes: kfs, duration: res.end, loop: false, delay: 0 });
11357
+ // Read the track back where the engine keeps it.
11358
+ const rr = app.relationRegistry;
11359
+ const written = rr && rr.getCameraAnimationParams ? rr.getCameraAnimationParams() : null;
11360
+ if (r === false || (rr && rr.getCameraAnimationParams && !written)) {
11361
+ res.warning = (res.warning ? res.warning + ' ' : '') + 'the camera track was not written; the morphs run without it.';
11362
+ } else {
11363
+ res.camera = { keyframes: kfs.length };
11364
+ }
11365
+ }
11366
+ ` : ''}${sound ? ` // A sound on each morph (D68's placement, with its MP4-lead nudge).
11367
+ const base = (app.sfxSpec && app.sfxSpec(${S(sound)})) || (app.percussionSpec && app.percussionSpec(${S(sound)}));
11368
+ if (base && app.createSound) {
11369
+ res.sounds = done.map(function (d) { const at = d.at < 0.06 ? 0.06 : d.at; const it = app.createSound(Object.assign({}, base), { startTime: at, duration: base.duration || 0.5 }); return { t: at, itemId: it && it.data && it.data.id }; });
11370
+ } else { res.warning = (res.warning ? res.warning + ' ' : '') + ${S(`no sound placed: ${sound} is not in the catalogue, or this studio cannot place sounds.`)}; }
11371
+ ` : ''} return res;
11372
+ })();`.trim();
11373
+ }
11374
+ /**
11375
+ * pinepaper_original_character (D66): app.createCharacter and its schema
11376
+ * helpers. The helpers read the SAME table the validator uses, so a model
11377
+ * reads the legal values (describe) before it guesses; a wrong one is
11378
+ * refused by name and nothing is drawn.
11379
+ */
11380
+ generateOriginalCharacter(input) {
11381
+ const S = (v) => JSON.stringify(v);
11382
+ const guard = `if (typeof app.createCharacter !== 'function') { return { success: false, error: 'this studio has no original characters (app.createCharacter) — it predates FxTool D66.' }; }`;
11383
+ switch (input.action) {
11384
+ case 'bases':
11385
+ return `(function() {\n ${guard}\n return { success: true, action: 'bases', bases: app.listCharacterBases() };\n})();`;
11386
+ case 'describe':
11387
+ return `(function() {\n ${guard}\n return { success: true, action: 'describe', schema: app.describeCharacterVariantSchema(${input.base ? S(input.base) : ''}) };\n})();`;
11388
+ case 'random':
11389
+ return `(function() {\n ${guard}\n const r = app.randomCharacterVariant(${S(input.base)}, ${S(input.seed)}${input.fixed ? `, { fixed: ${S(input.fixed)} }` : ''});\n if (!r || r.ok === false) return { success: false, action: 'random', error: (r && r.errors && r.errors.join('; ')) || 'no variant' };\n return { success: true, action: 'random', base: ${S(input.base)}, seed: ${S(input.seed)}, variant: r.variant };\n})();`;
11390
+ default: {
11391
+ const spec = { base: input.base, ...(input.variant ? { variant: input.variant } : {}), ...(input.at ? { x: input.at.x, y: input.at.y } : {}),
11392
+ ...(input.height !== undefined ? { height: input.height } : {}), ...(input.scale !== undefined ? { scale: input.scale } : {}), ...(input.rig !== undefined ? { rig: input.rig } : {}) };
11393
+ return `
11394
+ // Original character: create
11395
+ (async function() {
11396
+ ${guard}
11397
+ const r = await app.createCharacter(${S(spec)});
11398
+ if (!r || r.ok === false) return { success: false, action: 'create', errors: (r && r.errors) || [], error: (r && r.errors && r.errors.join('; ')) || 'the character was refused' };
11399
+ return { success: true, action: 'create', itemId: r.id, rigId: r.rigId, parts: r.parts, name: r.name, base: r.base, look: r.look, variant: r.variant };
11400
+ })();`.trim();
11401
+ }
11402
+ }
11403
+ }
11404
+ /**
11405
+ * pinepaper_look (D64): one look over an item, a subtree or the whole scene
11406
+ * — ids, keys and relations kept, removable pixel for pixel. Every apply
11407
+ * carries the look's own FIDELITY grade, and anything below 'convincing'
11408
+ * says so in the reply, so a weak approximation is never handed over as
11409
+ * the finished style.
11410
+ */
11411
+ generateLook(input) {
11412
+ const S = (v) => JSON.stringify(v);
11413
+ const guard = `if (typeof app.applyLook !== 'function') { return { success: false, error: 'this studio has no looks (app.applyLook) — it predates FxTool D64.' }; }`;
11414
+ const target = S(input.target ?? 'scene');
11415
+ switch (input.action) {
11416
+ case 'list':
11417
+ return `(function() {\n ${guard}\n return { success: true, action: 'list', looks: app.listLooks(), note: 'fidelity is the engine grade: native (the medium itself), faithful (close, approximations named), impression (an evocation, not the real style). Describe one before promising it.' };\n})();`;
11418
+ case 'describe':
11419
+ return `(function() {\n ${guard}\n const r = app.describeLook(${S(input.look)});\n if (!r || r.ok === false) return { success: false, action: 'describe', error: (r && r.error) || 'unknown look', known: r && r.known };\n return { success: true, action: 'describe', fidelity: r.look && r.look.fidelity, approximations: (r.look && r.look.approximations) || [], recipe: r.look, enginePaths: r.enginePaths, sceneOnly: r.sceneOnly };\n})();`;
11420
+ case 'get':
11421
+ return `(function() {\n ${guard}\n return { success: true, action: 'get', target: ${target}, look: app.getLook(${target}) || null };\n})();`;
11422
+ case 'remove':
11423
+ return `
11424
+ // Look: remove (restores what was there)
11425
+ (async function() {
11426
+ ${guard}
11427
+ const r = await app.removeLook(${target});
11428
+ if (!r || r.ok === false) return { success: false, action: 'remove', error: (r && r.error) || 'nothing to remove' };
11429
+ return { success: true, action: 'remove', target: ${target}, removed: r.removed, scene: r.scene };
11430
+ })();`.trim();
11431
+ case 'grid':
11432
+ return `
11433
+ // Look: a contact sheet of looks over the current scene
11434
+ (async function() {
11435
+ ${guard}
11436
+ const r = await app.renderLookGrid(${input.looks ? S(input.looks) : 'undefined'}, ${S({ ...(input.cols ? { cols: input.cols } : {}), ...(input.cellWidth ? { cellWidth: input.cellWidth } : {}), ...(input.t !== undefined ? { t: input.t } : {}) })});
11437
+ if (!r || r.ok === false || !r.dataURL) return { success: false, action: 'grid', error: (r && r.error) || 'the grid did not render' };
11438
+ return { success: true, action: 'grid', data: r.dataURL, mimeType: 'image/png', cells: (r.cells || []).map(function (c) { return { id: c.id, name: c.name, fidelity: c.fidelity }; }), cols: r.cols, rows: r.rows };
11439
+ })();`.trim();
11440
+ default: {
11441
+ const opts = input.scope ? { scope: input.scope } : {};
11442
+ return `
11443
+ // Look: apply
11444
+ (async function() {
11445
+ ${guard}
11446
+ const d = app.describeLook(${S(input.look)});
11447
+ const r = await app.applyLook(${target}, ${S(input.look)}, ${S(opts)});
11448
+ if (!r || r.ok === false) return { success: false, action: 'apply', error: (r && r.error) || 'the look was refused', known: r && r.known };
11449
+ const fidelity = (d && d.look && d.look.fidelity) || (r.look && r.look.fidelity) || null;
11450
+ const res = { success: true, action: 'apply', target: ${target}, look: r.look && (r.look.id || r.look.name), fidelity: fidelity,
11451
+ restyled: r.restyled, texts: r.texts, images: r.images, brushed: r.brushed, keyframes: r.keyframes, filters: r.filters, ids: r.ids,
11452
+ approximations: r.approximations || [], warnings: r.warnings || [] };
11453
+ // The engine's grades: native (the medium itself), faithful (close, with
11454
+ // named approximations), impression (an evocation), custom (an ad-hoc spec).
11455
+ const __approx = (res.approximations && res.approximations.length) ? res.approximations.join('; ') : '';
11456
+ if (fidelity === 'impression') {
11457
+ res.warning = 'this look is an IMPRESSION of the style (the engine\\'s grade)' + (__approx ? ' — it approximates: ' + __approx : '') + '. Present it as an approximation of the style, not the finished style.';
11458
+ } else if (fidelity === 'faithful' && __approx) {
11459
+ res.note = 'a faithful look; what it approximates: ' + __approx + '.';
11460
+ } else if (fidelity === 'custom') {
11461
+ res.note = 'a custom look spec: the engine has no fidelity grade for it.';
11462
+ }
11463
+ return res;
11464
+ })();`.trim();
11465
+ }
11466
+ }
11467
+ }
11468
+ /** pinepaper_emitter (D29): create('emitter', spec) / setEmitter(id, patch). */
11469
+ generateEmitter(input) {
11470
+ const S = (v) => JSON.stringify(v);
11471
+ if (input.action === 'set') {
11472
+ return `
11473
+ // Emitter: change a spec
11474
+ (function() {
11475
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
11476
+ const r = app.setEmitter(${S(input.itemId)}, ${S(input.spec)});
11477
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the emitter change was refused' }; }
11478
+ return { success: true, action: 'set', itemId: ${S(input.itemId)}, spec: r.spec };
11479
+ })();`.trim();
11480
+ }
11481
+ return `
11482
+ // Emitter: create
11483
+ (function() {
11484
+ if (typeof app.setEmitter !== 'function') { return { success: false, error: 'this studio has no emitter item (app.setEmitter) — it predates FxTool D29.' }; }
11485
+ const it = app.create('emitter', ${S(input.spec)});
11486
+ // A bad spec comes back null with an emitter-invalid warning; the reason
11487
+ // reaches this reply through the governor's warnings.
11488
+ if (!it) { return { success: false, error: 'the emitter spec was refused' }; }
11489
+ const id = it.data && (it.data.registryId || it.data.id);
11490
+ if (!id) { return { success: false, error: 'the emitter was made but not registered — no usable id' }; }
11491
+ return { success: true, action: 'create', itemId: id };
11492
+ })();`.trim();
11493
+ }
11494
+ /** pinepaper_render_hook (D29): drawing saved as source, re-installed on load. */
11495
+ generateRenderHook(input) {
11496
+ const S = (v) => JSON.stringify(v);
11497
+ const guard = `if (typeof app.registerRenderHook !== 'function') { return { success: false, error: 'this studio has no saved render hooks (app.registerRenderHook) — it predates FxTool D29.' }; }`;
11498
+ if (input.action === 'list') {
11499
+ return `(function() {\n ${guard}\n return { success: true, action: 'list', hooks: app.listRenderHooks() };\n})();`;
11500
+ }
11501
+ if (input.action === 'unregister') {
11502
+ return `(function() {\n ${guard}\n const had = app.unregisterRenderHook(${S(input.id)});\n if (!had) return { success: false, error: 'no render hook ' + ${S(input.id)} };\n return { success: true, action: 'unregister', id: ${S(input.id)} };\n})();`;
11503
+ }
11504
+ const spec = { id: input.id, source: input.source, ...(input.layer ? { layer: input.layer } : {}), ...(input.deterministic !== undefined ? { deterministic: input.deterministic } : {}), ...(input.seed !== undefined ? { seed: input.seed } : {}) };
11505
+ return `
11506
+ // Render hook: register (saved as source)
11507
+ (function() {
11508
+ ${guard}
11509
+ const r = app.registerRenderHook(${S(spec)});
11510
+ if (!r || r.ok === false) { return { success: false, error: (r && r.error) || 'the render hook was refused' }; }
11511
+ return { success: true, action: 'register', id: r.id, layer: r.layer, deterministic: r.deterministic, seed: r.seed };
11512
+ })();`.trim();
11513
+ }
11009
11514
  generateRelight(input) {
11010
11515
  const S = (v) => JSON.stringify(v);
11011
11516
  const missing = (fn) => `if (typeof app.${fn} !== 'function') { return { success: false, action: ${S(input.action)}, error: 'this studio has no relight pass (app.${fn}) — it predates FxTool D5.' }; }`;
@@ -11151,7 +11656,7 @@ ${body}
11151
11656
  }
11152
11657
  return { success: true, action: 'apply', medium: ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id || ${S(input.itemId)}, inPlace: true, styled: __styled, ...(r.note ? { note: r.note } : {}) };
11153
11658
  }
11154
- return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.note ? { note: r.note } : {}) };
11659
+ return { success: true, action: 'apply', medium: r.medium || ${S(input.medium)}, fidelity: r.fidelity, itemId: r.id, ...(r.filter ? { filter: r.filter } : {}), ...(r.note ? { note: r.note } : {}) };
11155
11660
  })();`.trim();
11156
11661
  case 'sdf_stroke': {
11157
11662
  // FxTool D5: an SDF capsule stroke rendered to a bitmap and created as a
@@ -12080,7 +12585,14 @@ ${needWorld}
12080
12585
  */
12081
12586
  generateStory(input) {
12082
12587
  const S = (v) => JSON.stringify(v);
12083
- const opts = S(input.options ?? {});
12588
+ // nav decides the story's FORM (D109): 'scenes' = a scene chain (video),
12589
+ // 'panels' = tabs (widget). storyFromText reads options.nav; a spec carries
12590
+ // its own nav, which a top-level nav fills in when the spec has none.
12591
+ const opts = S({ ...(input.options ?? {}), ...(input.nav ? { nav: input.nav } : {}) });
12592
+ const spec = input.spec && input.nav && input.spec.nav === undefined ? { ...input.spec, nav: input.nav } : input.spec;
12593
+ const cuts = input.nav === 'scenes'
12594
+ ? `\n // The video form's cut times (s), for checking the edit.\n try { if (app.sceneManager && app.sceneManager.getChainCuts) __out.cuts = app.sceneManager.getChainCuts(); } catch (_) {}`
12595
+ : '';
12084
12596
  switch (input.action) {
12085
12597
  case 'distill':
12086
12598
  return `
@@ -12106,16 +12618,18 @@ ${needWorld}
12106
12618
  // fine in-page. Not a cycle; JSON.stringify succeeded on this the whole time.
12107
12619
  // The groups have to stay for in-page callers, so the wrapper takes what
12108
12620
  // travels: the spec, the ids, the count.
12109
- return { success: true, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };
12621
+ const __out = { success: true, nav: (r.spec && r.spec.nav) || ${S(input.nav ?? 'panels')}, spec: r.spec, partIds: r.partIds, partCount: r.partCount, beats: r.beats };${cuts}
12622
+ return __out;
12110
12623
  })();`.trim();
12111
12624
  case 'apply_spec':
12112
12625
  return `
12113
12626
  // Story: assemble a spec you already have
12114
12627
  (async function() {
12115
12628
  if (typeof app.applyStorySpec !== 'function') { return { success: false, error: 'app.applyStorySpec unavailable — update FxTool' }; }
12116
- const r = await app.applyStorySpec(${S(input.spec)}, ${opts});
12629
+ const r = await app.applyStorySpec(${S(spec)}, ${opts});
12117
12630
  if (!r || r.ok === false) { return { success: false, error: (r && (r.reason || r.error)) || 'the spec did not assemble' }; }
12118
- return { success: true, ...r };
12631
+ const __out = { success: true, ...r };${cuts}
12632
+ return __out;
12119
12633
  })();`.trim();
12120
12634
  case 'plan_book':
12121
12635
  return `
@@ -13134,7 +13648,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
13134
13648
  generateEvent(input) {
13135
13649
  if (input.action === 'create') {
13136
13650
  const nameJson = JSON.stringify(input.name);
13137
- const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y });
13651
+ const optsJson = JSON.stringify({ payloadType: input.payloadType, x: input.x, y: input.y, ...(input.at !== undefined ? { at: input.at } : {}) });
13138
13652
  return `
13139
13653
  // Create event: ${input.name}
13140
13654
  (function() {
@@ -13144,7 +13658,7 @@ ${guard('unlockAllItems')}${pass('app.unlockAllItems()')}
13144
13658
  const eventId = app.createEvent(${nameJson}, ${optsJson});
13145
13659
  if (!eventId) { return { success: false, error: 'Event creation failed' }; }
13146
13660
  if (app.historyManager) app.historyManager.saveState();
13147
- return { success: true, action: 'create', eventId };
13661
+ return { success: true, action: 'create', eventId${input.at !== undefined ? `, at: ${JSON.stringify(input.at)}` : ''} };
13148
13662
  })();`.trim();
13149
13663
  }
13150
13664
  // pulse — a runtime fire, not a scene edit (no history save)