@pinepaper.studio/mcp-server 1.6.16 → 1.6.18

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 (90) 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 +142 -4
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/browser/puppeteer-controller.d.ts +61 -1
  11. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  12. package/dist/browser/puppeteer-controller.js +168 -6
  13. package/dist/browser/puppeteer-controller.js.map +1 -1
  14. package/dist/build-info.json +2 -2
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +75 -10
  18. package/dist/index.js.map +1 -1
  19. package/dist/ontology/ontology-validator.d.ts.map +1 -1
  20. package/dist/ontology/ontology-validator.js +9 -5
  21. package/dist/ontology/ontology-validator.js.map +1 -1
  22. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  23. package/dist/ontology/upstream-vocabulary.js +2 -2
  24. package/dist/tools/definitions.d.ts.map +1 -1
  25. package/dist/tools/definitions.js +198 -47
  26. package/dist/tools/definitions.js.map +1 -1
  27. package/dist/tools/engine-lists.d.ts +20 -0
  28. package/dist/tools/engine-lists.d.ts.map +1 -0
  29. package/dist/tools/engine-lists.js +86 -0
  30. package/dist/tools/engine-lists.js.map +1 -0
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +8 -0
  33. package/dist/tools/engine-methods.js.map +1 -1
  34. package/dist/tools/engine-surface.d.ts +1 -1
  35. package/dist/tools/engine-surface.d.ts.map +1 -1
  36. package/dist/tools/engine-surface.js +20 -4
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/beats.d.ts +64 -0
  39. package/dist/tools/handlers/beats.d.ts.map +1 -0
  40. package/dist/tools/handlers/beats.js +83 -0
  41. package/dist/tools/handlers/beats.js.map +1 -0
  42. package/dist/tools/handlers/brand-intake.d.ts +95 -0
  43. package/dist/tools/handlers/brand-intake.d.ts.map +1 -0
  44. package/dist/tools/handlers/brand-intake.js +242 -0
  45. package/dist/tools/handlers/brand-intake.js.map +1 -0
  46. package/dist/tools/handlers.d.ts +23 -11
  47. package/dist/tools/handlers.d.ts.map +1 -1
  48. package/dist/tools/handlers.js +265 -14
  49. package/dist/tools/handlers.js.map +1 -1
  50. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  51. package/dist/tools/minimal-descriptions.js +5 -3
  52. package/dist/tools/minimal-descriptions.js.map +1 -1
  53. package/dist/tools/shape-params.d.ts +2 -2
  54. package/dist/tools/shape-params.js +2 -2
  55. package/dist/tools/stick-vocabulary.d.ts +1 -1
  56. package/dist/tools/stick-vocabulary.js +1 -1
  57. package/dist/tools/toolkits.d.ts.map +1 -1
  58. package/dist/tools/toolkits.js +2 -0
  59. package/dist/tools/toolkits.js.map +1 -1
  60. package/dist/types/code-generator.d.ts +63 -2
  61. package/dist/types/code-generator.d.ts.map +1 -1
  62. package/dist/types/code-generator.js +533 -49
  63. package/dist/types/code-generator.js.map +1 -1
  64. package/dist/types/schemas.d.ts +2199 -640
  65. package/dist/types/schemas.d.ts.map +1 -1
  66. package/dist/types/schemas.js +186 -50
  67. package/dist/types/schemas.js.map +1 -1
  68. package/dist/utils/a11y.d.ts +2 -0
  69. package/dist/utils/a11y.d.ts.map +1 -1
  70. package/dist/utils/a11y.js +1 -1
  71. package/dist/utils/a11y.js.map +1 -1
  72. package/dist/utils/beats.d.ts +58 -0
  73. package/dist/utils/beats.d.ts.map +1 -0
  74. package/dist/utils/beats.js +110 -0
  75. package/dist/utils/beats.js.map +1 -0
  76. package/dist/utils/mcp-log.d.ts +16 -0
  77. package/dist/utils/mcp-log.d.ts.map +1 -0
  78. package/dist/utils/mcp-log.js +10 -0
  79. package/dist/utils/mcp-log.js.map +1 -0
  80. package/dist/utils/progress.d.ts +28 -0
  81. package/dist/utils/progress.d.ts.map +1 -0
  82. package/dist/utils/progress.js +24 -0
  83. package/dist/utils/progress.js.map +1 -0
  84. package/dist/utils/svg-size.d.ts +22 -0
  85. package/dist/utils/svg-size.d.ts.map +1 -0
  86. package/dist/utils/svg-size.js +43 -0
  87. package/dist/utils/svg-size.js.map +1 -0
  88. package/dist/vendor/design/design-style.js +1 -1
  89. package/manifest.json +9 -1
  90. package/package.json +7 -4
@@ -8,6 +8,7 @@
8
8
  * 3. TypeScript type inference
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { ENGINE_EASING_NAMES, DESIGN_MEDIA, DESIGN_MEDIA_APPLY } from '../tools/engine-lists.js';
11
12
  // =============================================================================
12
13
  // COMMON SCHEMAS
13
14
  // =============================================================================
@@ -70,14 +71,77 @@ export const BlendModeSchema = z.enum([
70
71
  'difference',
71
72
  'exclusion',
72
73
  ]).describe('Blend mode for compositing with background');
73
- export const EasingSchema = z.enum([
74
- 'linear',
75
- 'easeIn',
76
- 'easeOut',
77
- 'easeInOut',
78
- 'bounce',
79
- 'elastic',
80
- ]).describe('Easing function for animation');
74
+ /**
75
+ * Every easing name the engine's table accepts: GENERATED from FxTool's
76
+ * EASING_NAMES (scripts/sync-engine-lists.mjs, checked in prepublishOnly).
77
+ * Keyframes, masks, relations and the camera all resolve a name through that
78
+ * one table, so every easing enum here is this list.
79
+ */
80
+ export const KEYFRAME_EASINGS = ENGINE_EASING_NAMES;
81
+ /** One line per easing, for tool descriptions and pinepaper_get_available_easings. */
82
+ export const EASING_DESCRIPTIONS = {
83
+ linear: 'Constant speed',
84
+ easeIn: 'Slow start, fast end',
85
+ easeOut: 'Fast start, slow end',
86
+ easeInOut: 'Slow start and end',
87
+ easeInCubic: 'Slow start, stronger than easeIn',
88
+ easeOutCubic: 'Slow end, stronger than easeOut',
89
+ easeInOutCubic: 'Slow start and end, stronger than easeInOut',
90
+ bounce: 'Bounces to rest at the end (same as easeOutBounce)',
91
+ easeOutBounce: 'Bounces to rest at the end',
92
+ easeInBounce: 'Bounces at the start, then moves',
93
+ elastic: 'Overshoots and oscillates at the end (same as easeOutElastic)',
94
+ easeOutElastic: 'Overshoots and oscillates at the end',
95
+ easeInElastic: 'Oscillates at the start, then moves',
96
+ easeOutBack: 'Overshoots the target slightly, then settles',
97
+ easeInBack: 'Pulls back first, then moves',
98
+ easeInOutBack: 'Pulls back first and overshoots at the end',
99
+ spring: 'Damped spring, ζ≈1: settles with no overshoot',
100
+ springSnappy: 'Damped spring, ζ≈0.69: quick, one ~5% overshoot',
101
+ springPlayful: 'Damped spring, ζ≈0.45: ~21% overshoot, visible wobbles',
102
+ easeInQuad: 'Quadratic slow start (same as easeIn)',
103
+ easeOutQuad: 'Quadratic slow end (same as easeOut)',
104
+ easeInOutQuad: 'Quadratic slow start and end (same as easeInOut)',
105
+ easeInSine: 'Gentle slow start (sine)',
106
+ easeOutSine: 'Gentle slow end (sine)',
107
+ easeInOutSine: 'Gentle slow start and end (sine)',
108
+ easeInQuart: 'Slow start, stronger than cubic (t⁴)',
109
+ easeOutQuart: 'Slow end, stronger than cubic',
110
+ easeInOutQuart: 'Slow start and end, stronger than cubic',
111
+ easeInQuint: 'Very slow start (t⁵)',
112
+ easeOutQuint: 'Very slow end',
113
+ easeInOutQuint: 'Very slow start and end',
114
+ easeInExpo: 'Near-still start, then fast (the standard expo curve)',
115
+ easeOutExpo: 'Fast, then a long near-still end (the standard expo curve)',
116
+ easeInOutExpo: 'Near-still at both ends, fast in the middle',
117
+ easeInCirc: 'Slow start, sharp finish (circular)',
118
+ easeOutCirc: 'Sharp start, slow finish (circular)',
119
+ easeInOutCirc: 'Circular slow start and end',
120
+ };
121
+ export const EasingSchema = z.enum(KEYFRAME_EASINGS).describe('Easing function for animation');
122
+ /**
123
+ * A keyframe's easing: a name from the table, or a custom cubic Bézier
124
+ * [x1, y1, x2, y2]. The interpolator runs the array natively, the validator
125
+ * accepts it, and every export path handles it (SMIL clamps y into keySplines;
126
+ * CSS and Lottie take the raw curve). Keyframe tracks only — keyframe_animate,
127
+ * camera_animate and inline keyframes — not masks or relations (fxtool-d7).
128
+ */
129
+ export const BezierEasingSchema = z.tuple([z.number(), z.number(), z.number(), z.number()])
130
+ .refine(([x1, , x2]) => x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1, {
131
+ message: 'cubic-bezier x1 and x2 must be within 0..1 (y1 and y2 may exceed it for overshoot)',
132
+ })
133
+ .describe('Custom cubic-bezier [x1, y1, x2, y2]');
134
+ export const KeyframeEasingSchema = z.union([EasingSchema, BezierEasingSchema]);
135
+ /** JSON-Schema form of KeyframeEasingSchema, for tool definitions. */
136
+ export const KEYFRAME_EASING_JSON_SCHEMA = {
137
+ oneOf: [
138
+ { type: 'string', enum: [...KEYFRAME_EASINGS] },
139
+ {
140
+ type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4,
141
+ description: 'or a custom cubic-bezier [x1, y1, x2, y2] — x in 0..1, y may exceed 0..1 for overshoot',
142
+ },
143
+ ],
144
+ };
81
145
  // =============================================================================
82
146
  // ITEM TYPES
83
147
  // =============================================================================
@@ -251,7 +315,7 @@ export const SimpleAnimationTypeSchema = z.enum([
251
315
  export const KeyframeSchema = z.object({
252
316
  time: z.number().describe('Time in seconds for this keyframe'),
253
317
  properties: z.record(z.unknown()).describe('Property values at this keyframe'),
254
- easing: EasingSchema.optional().default('linear'),
318
+ easing: KeyframeEasingSchema.optional().default('linear'),
255
319
  });
256
320
  // =============================================================================
257
321
  // RELATION TYPES
@@ -1149,6 +1213,8 @@ export const ModifyItemInputSchema = z.object({
1149
1213
  itemId: z.string().describe('Registry ID of the item'),
1150
1214
  properties: z.record(z.unknown()).describe('Properties to update'),
1151
1215
  data: ItemDataFlagsSchema.optional(),
1216
+ atTime: z.number().min(0).optional().describe('AUTO-KEY: write these properties as keyframes at this time (seconds) instead of a static edit — eased from the previous value, other keys kept.'),
1217
+ easing: z.string().optional().describe('atTime: easing of the segment arriving at the new key (default linear).'),
1152
1218
  });
1153
1219
  // Delete Item
1154
1220
  export const DeleteItemInputSchema = z.object({
@@ -1367,13 +1433,16 @@ export const SetBackgroundColorInputSchema = z.object({
1367
1433
  color: SolidColorSchema.describe('Background color to set (solid colors only)'),
1368
1434
  });
1369
1435
  export const SetCanvasSizeInputSchema = z.object({
1370
- // 8192, not 4096: the artboard tool takes any size and a 5400x1350 carousel
1371
- // panorama exported exactly, while this refused it. 8192 stays well inside
1372
- // the browser's per-side canvas limit.
1436
+ // 16384: the engine's own safe dimension for Chrome / Firefox
1437
+ // (ExportEngine.CANVAS_LIMITS; its hard limit is 32767 per side and 2^28
1438
+ // pixels), and what DOOH / projection canvases need (round 12: 8220 and
1439
+ // 12000 px wide rendered fine while this refused them at 8192, which is
1440
+ // Safari's safe size). Above 8192 the result warns that Safari / iOS may
1441
+ // not manage it.
1373
1442
  // The refusal NAMES the route: a 728x90 banner refused with a bare "too
1374
1443
  // small" left a caller to export the preset instead (round 8 BB, 1.48).
1375
- width: z.number().min(100, "a canvas side under 100 px is not supported. For small assets (a 728x90 or 320x50 banner, a 28 px emote) design at 2-4x — e.g. 1456x180 — and export with agent_export region {x:0, y:0, width, height, outputWidth:728, outputHeight:90}. For VIDEO at a small size, design at 2x and export with agent_export scale: 0.5.").max(8192).describe('Canvas width (100-8192)'),
1376
- height: z.number().min(100, "a canvas side under 100 px is not supported. For small assets (a 728x90 or 320x50 banner, a 28 px emote) design at 2-4x — e.g. 1456x180 — and export with agent_export region {x:0, y:0, width, height, outputWidth:728, outputHeight:90}. For VIDEO at a small size, design at 2x and export with agent_export scale: 0.5.").max(8192).describe('Canvas height (100-8192)'),
1444
+ width: z.number().min(100, "a canvas side under 100 px is not supported. For small assets (a 728x90 or 320x50 banner, a 28 px emote) design at 2-4x — e.g. 1456x180 — and export with agent_export region {x:0, y:0, width, height, outputWidth:728, outputHeight:90}. For VIDEO at a small size, design at 2x and export with agent_export scale: 0.5.").max(16384).describe('Canvas width (100-16384; above 8192 Safari / iOS may fail)'),
1445
+ height: z.number().min(100, "a canvas side under 100 px is not supported. For small assets (a 728x90 or 320x50 banner, a 28 px emote) design at 2-4x — e.g. 1456x180 — and export with agent_export region {x:0, y:0, width, height, outputWidth:728, outputHeight:90}. For VIDEO at a small size, design at 2x and export with agent_export scale: 0.5.").max(16384).describe('Canvas height (100-16384; above 8192 Safari / iOS may fail)'),
1377
1446
  preset: z.string().optional().describe('Optional preset name'),
1378
1447
  });
1379
1448
  // Export
@@ -1816,9 +1885,7 @@ export const MaskPresetSchema = z.enum([
1816
1885
  export const MaskTypeSchema = z.enum([
1817
1886
  'rectangle', 'circle', 'ellipse', 'star', 'triangle', 'hexagon', 'heart', 'rounded',
1818
1887
  ]).describe('Mask shape type');
1819
- export const MaskEasingSchema = z.enum([
1820
- 'linear', 'easeIn', 'easeOut', 'easeInOut', 'bounce', 'elastic',
1821
- ]).describe('Mask animation easing function');
1888
+ export const MaskEasingSchema = z.enum(KEYFRAME_EASINGS).describe('Mask animation easing function');
1822
1889
  export const MaskKeyframePropertiesSchema = z.object({
1823
1890
  x: z.number().optional(),
1824
1891
  y: z.number().optional(),
@@ -1997,8 +2064,8 @@ export const AgentBatchOperationSchema = z.object({
1997
2064
  generatorParams: z.record(z.unknown()).optional().describe('Generator parameters'),
1998
2065
  generatorRegion: GeneratorRegionSchema.optional().describe('Optional sub-region {x, y, width, height} for execute_generator'),
1999
2066
  // Set canvas size fields
2000
- width: z.number().min(100).max(8192).optional().describe('Canvas width for set_canvas_size (100-8192)'),
2001
- height: z.number().min(100).max(8192).optional().describe('Canvas height for set_canvas_size (100-8192)'),
2067
+ width: z.number().min(100).max(16384).optional().describe('Canvas width for set_canvas_size (100-16384)'),
2068
+ height: z.number().min(100).max(16384).optional().describe('Canvas height for set_canvas_size (100-16384)'),
2002
2069
  preset: z.string().optional().describe('Canvas preset for set_canvas_size (e.g. instagram, youtube)'),
2003
2070
  // Keyframe animate fields
2004
2071
  keyframes: z.array(z.object({
@@ -2187,6 +2254,13 @@ export const AgentExportInputSchema = z.object({
2187
2254
  loop: z.union([z.boolean(), z.number().int().min(0).max(1000)]).optional().describe('gif / apng: true = loop forever, false / 0 / 1 = play once, n = play n times.'),
2188
2255
  maxBytes: z.number().int().positive().optional().describe('gif only: a size budget in bytes (email wants <= 1 MB). Over it, the GIF is re-encoded smaller — frame size scaled from the overshoot — at most twice; the result reports each attempt and whether the budget was met.'),
2189
2256
  broadcast: z.boolean().optional().describe('mp4 only: broadcast-safe — BT.709, limited range (samples 16-235), tagged bt709, constant bitrate with an 8 Mbps floor at 720p and up (4 below). result.video reports what the encoder did.'),
2257
+ frames: z.number().int().min(1).max(36_000).optional().describe('Animated formats: export exactly this many frames; it takes the place of duration (duration x fps frames is rounded UP for mp4 / webm / gif). For frame-exact segments to assemble.'),
2258
+ // FxTool #55 (motion-course G2): N sub-frames per frame, averaged across
2259
+ // the shutter, so fast motion smears the way a camera's does.
2260
+ motionBlur: z.object({
2261
+ subframes: z.number().int().min(1).max(32).optional().describe('Renders per frame (default 8; 1 = no blur). Export time scales with it.'),
2262
+ shutter: z.number().min(0).max(1).optional().describe('Fraction of the frame interval the shutter is open (default 0.5, a 180° shutter).'),
2263
+ }).optional().describe('mp4 / webm: real motion blur. Each frame is rendered subframes times across the shutter and averaged, so fast movement smears instead of strobing. Costs subframes× the render time: run estimateOnly first, which reports renders and renderCost.'),
2190
2264
  deterministic: z.boolean().optional().describe('mp4 / webm: byte-identical files for the same scene, so an export can be checksummed or cached by hash. The container\'s creation / modification timestamps are pinned; nothing about the pixels changes.'),
2191
2265
  broadcastHeadroom: z.number().int().min(0).max(40).optional().describe('mp4 with broadcast only (default 12 with broadcast): luma codes kept clear at both ends of 16-235, so encoder ringing stays legal. 12 measured clean on hard edges; 4 cuts out-of-range samples ~100x, 12 ~1000x; never to zero, so a legaliser pass is still required (result fidelity names it).'),
2192
2266
  bitrate: z.number().int().min(100_000).max(200_000_000).optional().describe('mp4 / webm: target bits per second, replacing the quality-derived one. The browser encoder treats it as a CEILING: simple content comes out lower, and result.video.achievedBitrate says what it was.'),
@@ -2196,8 +2270,8 @@ export const AgentExportInputSchema = z.object({
2196
2270
  region: z.object({
2197
2271
  x: z.number(), y: z.number(),
2198
2272
  width: z.number().positive(), height: z.number().positive(),
2199
- outputWidth: z.number().int().positive().max(8192).optional(),
2200
- outputHeight: z.number().int().positive().max(8192).optional(),
2273
+ outputWidth: z.number().int().positive().max(16384).optional(),
2274
+ outputHeight: z.number().int().positive().max(16384).optional(),
2201
2275
  excludeForeign: z.boolean().optional().describe('Default true: an item that reaches into the region but is centred outside it (a neighbouring card\'s overflow) is left out of this image; result.excludedItems names them. false renders everything that touches the region.'),
2202
2276
  }).optional().describe('png / jpg / webp only: export just this canvas region (canvas coordinates, top-left x/y) — carousel slices, crops. Output is the region\'s size unless outputWidth/outputHeight say otherwise; a different aspect is covered, not stretched.'),
2203
2277
  }).describe('Smart export options')
@@ -2211,7 +2285,10 @@ export const AgentExportInputSchema = z.object({
2211
2285
  if (val.broadcastHeadroom !== undefined && !val.broadcast) {
2212
2286
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['broadcastHeadroom'], message: 'broadcastHeadroom applies only with broadcast: true (mp4).' });
2213
2287
  }
2214
- for (const k of ['bitrate', 'minBitrate', 'bitrateMode', 'deterministic']) {
2288
+ if (val.frames !== undefined && !['mp4', 'webm', 'gif', 'apng'].includes(String(val.format))) {
2289
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['frames'], message: 'frames applies to animated formats: mp4, webm, gif, apng.' });
2290
+ }
2291
+ for (const k of ['bitrate', 'minBitrate', 'bitrateMode', 'deterministic', 'motionBlur']) {
2215
2292
  if (val[k] !== undefined && val.format !== 'mp4' && val.format !== 'webm') {
2216
2293
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: [k], message: `${k} is a video setting: mp4 or webm. A gif's size is set by maxBytes and scale.` });
2217
2294
  }
@@ -2256,14 +2333,14 @@ export const AgentExportInputSchema = z.object({
2256
2333
  // Same shape as the wav refusal below: a knob that cannot apply is said
2257
2334
  // once rather than accepted and dropped. png/svg/pdf carry no encode
2258
2335
  // target, so scaling them here would promise a resize that never happens.
2259
- if (val.fps !== undefined && !['mp4', 'webm', 'gif', 'auto'].includes(String(val.format))) {
2336
+ if (val.fps !== undefined && !['mp4', 'webm', 'gif', 'apng', 'auto'].includes(String(val.format))) {
2260
2337
  ctx.addIssue({
2261
2338
  code: z.ZodIssueCode.custom,
2262
2339
  path: ['fps'],
2263
- message: `fps applies to video formats (mp4/webm/gif), not "${val.format}" — a still has no frame rate.`,
2340
+ message: `fps applies to animated formats (mp4/webm/gif/apng), not "${val.format}" — a still has no frame rate.`,
2264
2341
  });
2265
2342
  }
2266
- if (val.scale !== undefined && !['mp4', 'webm', 'gif', 'auto'].includes(String(val.format))) {
2343
+ if (val.scale !== undefined && !['mp4', 'webm', 'gif', 'apng', 'auto'].includes(String(val.format))) {
2267
2344
  ctx.addIssue({
2268
2345
  code: z.ZodIssueCode.custom,
2269
2346
  path: ['scale'],
@@ -2694,6 +2771,8 @@ export const ImportImageInputSchema = z.object({
2694
2771
  position: PositionSchema.optional().describe('Position on canvas (defaults to center)'),
2695
2772
  maxWidth: z.number().optional().describe('Maximum width constraint in pixels'),
2696
2773
  maxHeight: z.number().optional().describe('Maximum height constraint in pixels'),
2774
+ maxEdge: z.union([z.number().int().positive(), z.literal('native')]).optional().describe("The longest edge the studio STORES the image at, in px; 'native' keeps every pixel. Default: the studio's cap (it follows the canvas on desktop)."),
2775
+ nativeSize: z.boolean().optional().describe('Place the image at its source footprint even if it was stored smaller.'),
2697
2776
  mask: ImageMaskSchema.optional().describe('Optional mask shape to apply to the image'),
2698
2777
  smoothing: z.enum(['off', 'low', 'medium', 'high']).optional().describe("Resampling when the image is scaled. 'off' = nearest-neighbour, hard pixel edges (pixel art). Default 'low'."),
2699
2778
  }).describe('Import image input');
@@ -2989,8 +3068,10 @@ export const TransformInputSchema = z.object({
2989
3068
  // count is already the thing the consolidation backlog complains about, and
2990
3069
  // `pinepaper_media` / `pinepaper_transform` set the precedent.
2991
3070
  export const BrandKitInputSchema = z.object({
2992
- /** 'plan' reports what WOULD change without touching the scene. */
2993
- action: z.enum(['plan', 'apply']),
3071
+ /** 'plan' reports what WOULD change without touching the scene. 'from_url' reads a site into a proposed kit. */
3072
+ action: z.enum(['plan', 'apply', 'from_url']),
3073
+ url: z.string().url().optional().describe("from_url: the product's site (http/https). Read in an isolated browser context: the studio is not navigated."),
3074
+ screenshots: z.number().int().min(1).max(6).optional().describe('from_url: screenshots taken down the page (default 3).'),
2994
3075
  // Spelled out rather than a loose record. A first version typed this as
2995
3076
  // `z.record(...)` described as a "role → colour map", which is NOT the shape
2996
3077
  // the engine takes — every call was rejected with "name is required;
@@ -3011,9 +3092,11 @@ export const BrandKitInputSchema = z.object({
3011
3092
  heading: z.string().optional(),
3012
3093
  body: z.string().optional(),
3013
3094
  }).optional(),
3014
- }),
3095
+ }).optional(),
3015
3096
  selectionOnly: z.boolean().optional(),
3016
- });
3097
+ })
3098
+ .refine((v) => v.action === 'from_url' || !!v.kit, { message: 'plan and apply require kit (from_url proposes one)', path: ['kit'] })
3099
+ .refine((v) => v.action !== 'from_url' || (!!v.url && /^https?:\/\//i.test(v.url)), { message: 'from_url requires an http(s) url', path: ['url'] });
3017
3100
  export const ComponentInputSchema = z.object({
3018
3101
  action: z.enum(['define', 'list', 'instantiate', 'set_override', 'sync', 'update_from_instance', 'detach']),
3019
3102
  itemIds: z.array(z.string()).optional(),
@@ -3153,6 +3236,13 @@ export const AccessibilityCheckInputSchema = z.object({
3153
3236
  duration: z.number().positive().max(120).optional().describe('flash: seconds of the timeline to sample (default 5).'),
3154
3237
  fps: z.number().int().min(10).max(60).optional().describe('flash: samples per second (default 20; a flash rate is only seen below half of it).'),
3155
3238
  });
3239
+ // QUERY MUTATIONS (plan C5a): a property's keyframe series.
3240
+ export const QueryMutationsInputSchema = z.object({
3241
+ itemId: z.string().min(1),
3242
+ property: z.string().min(1).optional().describe('One property; all animated properties when omitted.'),
3243
+ range: z.tuple([z.number().min(0), z.number().min(0)]).optional().describe('[from, to] seconds.'),
3244
+ fps: z.number().positive().max(240).optional().describe('For the frame numbers (default 30).'),
3245
+ });
3156
3246
  // RENDER BATCH (data-driven creative): one scene, many rows, one export per
3157
3247
  // row. The handler runs each row through modify_item / template_params and
3158
3248
  // then agent_export, so every per-tool check applies to every row.
@@ -3443,11 +3533,34 @@ export const ValidateSceneInputSchema = z.object({
3443
3533
  });
3444
3534
  // Deterministic headless frame capture (S3) — evaluate the scene at each time via
3445
3535
  // sceneAt(t) with Math.random seeded once around the whole sequence, then snapshot.
3536
+ /**
3537
+ * A contact sheet: many frames tiled into ONE small image, saved to a file,
3538
+ * so an agent can look at its own film (G3 critique loop). Built on the same
3539
+ * deterministic capture; the times come from `times`, or from one of the
3540
+ * modes below.
3541
+ */
3542
+ export const CaptureSheetSchema = z.object({
3543
+ every: z.number().positive().optional().describe('One frame every N seconds from 0 to duration (needs duration).'),
3544
+ duration: z.number().positive().optional().describe('Scene length in seconds, for every and loopSeam.'),
3545
+ strip: z.object({
3546
+ at: z.number().min(0).describe('Centre time in seconds.'),
3547
+ count: z.number().int().min(2).max(24).default(8),
3548
+ step: z.number().positive().default(1 / 30).describe('Seconds between frames (default one frame at 30 fps).'),
3549
+ }).optional().describe('Consecutive frames around a moment: to check a transition or a stutter.'),
3550
+ loopSeam: z.boolean().optional().describe('The last frame next to the first (needs duration): does the loop join without a jump?'),
3551
+ tileWidth: z.number().int().min(120).max(1080).default(360).describe('Width of each tile in px. 360 is phone width: read every word at this size.'),
3552
+ columns: z.number().int().min(1).max(12).optional().describe('Tiles per row (default: as square as the count allows, at most 6).'),
3553
+ format: z.enum(['jpeg', 'png']).default('jpeg'),
3554
+ labels: z.boolean().default(true).describe('Stamp each tile with its time.'),
3555
+ });
3446
3556
  export const CaptureFramesInputSchema = z.object({
3447
- times: z.array(z.number()).min(1).describe('Capture times in seconds, rendered in order.'),
3557
+ times: z.array(z.number()).min(1).optional().describe('Capture times in seconds, rendered in order. Required unless sheet gives every, strip or loopSeam.'),
3448
3558
  seed: z.number().optional().describe('Seed Math.random once around the whole sequence so random generators/particles are reproducible (default 0).'),
3449
3559
  includeDataUrls: z.boolean().optional().describe('Include each frame as a PNG data URL (large — token-heavy). Default false → returns a cheap per-frame hash + byte size only.'),
3450
- });
3560
+ sheet: CaptureSheetSchema.optional().describe('Return ONE tiled image (saved to a file) instead of per-frame hashes, plus a critique rubric.'),
3561
+ })
3562
+ .refine((v) => !!v.times || !!(v.sheet && (v.sheet.every || v.sheet.strip || v.sheet.loopSeam)), { message: 'give times, or sheet with every, strip or loopSeam', path: ['times'] })
3563
+ .refine((v) => !v.sheet || !(v.sheet.every || v.sheet.loopSeam) || !!v.sheet.duration || !!v.times, { message: 'sheet.every and sheet.loopSeam need sheet.duration (the scene length in seconds)', path: ['sheet', 'duration'] });
3451
3564
  // Ontology→scene compiler (S12-E3). Compiles a pp:-namespaced JSON-LD design graph
3452
3565
  // (typed nodes + structural edges like pp:onTopOf) into a real scene. Compilation
3453
3566
  // runs server-side (deterministic, testable); only the resulting create/addRelation
@@ -3474,20 +3587,27 @@ export const LintSceneInputSchema = z.object({
3474
3587
  // Media (video/audio) via window.PinePaperAgent. Agent-facing surface is URL-based
3475
3588
  // (the agent can't hand over a File). Uploaded media are first-class canvas items.
3476
3589
  export const MediaInputSchema = z.object({
3477
- action: z.enum(['upload_video', 'upload_audio', 'list', 'remove', 'set_playback_rate', 'set_clip', 'split', 'add_transition', 'set_time_remap', 'speed_ramp', 'match_cut', 'apply_track_matte', 'stop_live_matte'])
3590
+ action: z.enum(['upload_video', 'upload_audio', 'list', 'remove', 'set_playback_rate', 'set_clip', 'split', 'add_transition', 'set_time_remap', 'speed_ramp', 'match_cut', 'apply_track_matte', 'stop_live_matte', 'analyze_reference', 'remake_from_reference'])
3478
3591
  .describe("'upload_video' / 'upload_audio' (from a URL) · 'list' media · 'remove' by id · 'set_playback_rate' · 'set_clip' (re-trim; with timeOffset also moves the clip) · 'split' (cut a clip in two at canvas time `at`; the first half keeps its id) · 'add_transition' (crossfade or dip between fromItemId, which ends at the cut, and toItemId, which starts there) · 'set_time_remap' (canvas-time→source-time curve: ramps, freezes, reverse) · 'speed_ramp' ({duration, speed} segments — the human way to say a remap) · 'match_cut' (cut between two shots aligning the SUBJECT via on-device detection) · 'apply_track_matte' (an item's alpha driven by another item's luma/alpha — type-filled-with-footage; live:true keeps it tracking as the matte animates) · 'stop_live_matte'"),
3479
3592
  // Not .url(): a bare absolute path is not a URL, and 48f3a2a made the server
3480
3593
  // read local files — so `/Users/…/broll1.mp4` was advertised and then rejected
3481
3594
  // by the schema before anything could read it. resolveMediaSource validates
3482
3595
  // what it is given and names the problem.
3483
3596
  url: z.string().min(1).optional().describe('Required for upload_video / upload_audio: an http(s) URL, a data: URL, or a local file path (absolute, relative to the server, or file://).'),
3484
- id: z.string().optional().describe('Media id — required for remove / set_playback_rate / set_clip / split.'),
3597
+ id: z.string().optional().describe('Media id — required for remove / set_playback_rate / set_clip / split / analyze_reference.'),
3598
+ // G4 reference intake (FxTool #52): measure a reference video's FORMAT and
3599
+ // build on it. Nothing of its footage, words or sound is copied.
3600
+ texts: z.array(z.string()).optional().describe('remake_from_reference: your words, one per shot of the reference (a short list is reused across shots).'),
3601
+ duration: z.number().positive().optional().describe('remake_from_reference: total length in seconds (default the reference\'s own).'),
3602
+ clear: z.boolean().optional().describe('remake_from_reference: empty the canvas first (media survives).'),
3603
+ thumbnails: z.boolean().optional().describe('analyze_reference: include a small image per shot (default false: they are inline images and token-heavy).'),
3485
3604
  rate: z.number().min(0.25).max(4).optional().describe('Playback rate 0.25–4 — required for set_playback_rate.'),
3486
3605
  inPoint: z.number().min(0).optional().describe('Clip in-point in media-time seconds — required for set_clip.'),
3487
3606
  outPoint: z.number().min(0).optional().describe('Clip out-point in media-time seconds — required for set_clip.'),
3488
3607
  // upload_video placement
3489
3608
  position: PositionSchema.optional().describe('Canvas position — upload_video.'),
3490
- scale: z.number().positive().optional().describe('Scale factor — upload_video.'),
3609
+ scale: z.number().positive().optional().describe("upload_video: a multiplier on the FIT the studio applies (fit: 'contain' by default), not on the video's own pixels — 0.7 of a contain-fit 1920 clip on a 1080 canvas is not 0.7 x 1920. Pass fit: 'none' for scale to be of the source pixels."),
3610
+ fit: z.enum(['contain', 'cover', 'none']).optional().describe("upload_video: how the clip is sized before scale — 'contain' (default; whole clip inside the canvas), 'cover' (fills it, cropped), 'none' (source pixels). The result gives the scale and bounds that landed."),
3491
3611
  timeOffset: z.number().optional().describe('Start offset on the timeline in seconds — upload_video / upload_audio; set_clip: move the clip to start here (canvas seconds).'),
3492
3612
  clipInPoint: z.number().optional().describe('Trim in-point in seconds — upload_video.'),
3493
3613
  clipOutPoint: z.number().optional().describe('Trim out-point in seconds — upload_video.'),
@@ -3530,6 +3650,8 @@ export const MediaInputSchema = z.object({
3530
3650
  atPlayhead: z.boolean().optional().describe('upload_video / upload_audio: start the clip at the current playback time (instead of timeOffset).'),
3531
3651
  })
3532
3652
  .refine((v) => !(v.action === 'upload_video' || v.action === 'upload_audio') || !!v.url, { message: 'upload requires url', path: ['url'] })
3653
+ .refine((v) => v.action !== 'analyze_reference' || !!v.id, { message: 'analyze_reference requires id: the uploaded reference clip (upload_video it first)', path: ['id'] })
3654
+ .refine((v) => v.action !== 'remake_from_reference' || (!!v.texts && v.texts.length > 0), { message: 'remake_from_reference requires texts: your own words, one per shot', path: ['texts'] })
3533
3655
  .refine((v) => !(v.action === 'remove' || v.action === 'set_playback_rate' || v.action === 'set_clip' || v.action === 'split') || !!v.id, { message: 'this action requires id', path: ['id'] })
3534
3656
  .refine((v) => v.action !== 'split' || (v.at !== undefined && v.at >= 0), { message: 'split requires at (canvas seconds) — where to cut', path: ['at'] })
3535
3657
  .refine((v) => v.action !== 'add_transition' || (!!v.fromItemId && !!v.toItemId), { message: 'add_transition requires fromItemId (the clip ending at the cut) and toItemId (the clip starting at it)', path: ['fromItemId'] })
@@ -3615,17 +3737,20 @@ export const TextStyleInputSchema = z.object({
3615
3737
  * `list_media` before promising a medium in prose.
3616
3738
  */
3617
3739
  export const DesignMediumInputSchema = z.object({
3618
- action: z.enum(['list_media', 'resolve', 'list_stitches', 'apply_thread', 'apply_hatch', 'list_flow_fields', 'list_hatch_options'])
3619
- .describe("'list_media' (7 media with fidelity + limitation) · 'resolve' (can this medium be made here, and how honestly) · 'list_stitches' · 'apply_thread' (render an item in thread) · 'apply_hatch' (rule an item with hatching — value through line density) · 'list_flow_fields' · 'list_hatch_options'"),
3620
- medium: z.string().optional().describe("resolve: medium key — vector, thread, hatch, watercolor, ink, cutPaper, charcoal, oil, encaustic. Call list_media for the live set with each one's fidelity; the catalogue grows."),
3621
- itemId: z.string().optional().describe('apply_thread / apply_hatch: a closed path, compound path, or a group of them. Its own silhouette is the region and its fill is the ink colour.'),
3740
+ action: z.enum(['list_media', 'resolve', 'list_stitches', 'apply_thread', 'apply_hatch', 'list_flow_fields', 'list_hatch_options', 'apply'])
3741
+ .describe("'apply' (render an item in ANY medium the engine can make: medium + options — watercolor, oil, cutPaper, thread, hatch) · 'list_media' (every medium with fidelity + limitation) · 'resolve' (can this medium be made here, and how honestly) · 'list_stitches' · 'apply_thread' (render an item in thread) · 'apply_hatch' (rule an item with hatching — value through line density) · 'list_flow_fields' · 'list_hatch_options'"),
3742
+ medium: z.string().optional().describe(`resolve / apply: medium key — ${DESIGN_MEDIA.join(', ')}. apply works for ${Object.keys(DESIGN_MEDIA_APPLY).join(', ')}; the others are refused by the engine with the reason (encaustic is absent here; charcoal and ink are brushes, not fills).`),
3743
+ options: z.record(z.string(), z.unknown()).optional().describe('apply: the medium\'s own options. watercolor: color, brush, water 0..1, opacity, edge 0..1 (the dark rim), angle, bleed, seed, keepSource. oil: color, brush, length, density, toneRange, relief, angle, seed, keepSource. cutPaper: color, facet, jitter, shadow, shadowColor, shadowOpacity, shadowOffset [dx, dy], seed, keepSource. thread / hatch: as apply_thread / apply_hatch.'),
3744
+ itemId: z.string().optional().describe('apply / apply_thread / apply_hatch: a closed path, compound path (holes kept), or a group of them (applied per path). The result takes the source\'s place in the stacking order and the source is hidden; applying again replaces it. One undo step.'),
3622
3745
  stitch: z.enum(THREAD_STITCHES).optional()
3623
3746
  .describe("Which stitch, when the medium is thread. Default longAndShort. These six are THE_STITCH_OPS the engine publishes — call 'list_stitches' for each one's description and its own parameters. An unknown name is refused by the engine rather than quietly stitched as a default fill."),
3624
3747
  field: z.object({
3625
3748
  kind: z.enum(['radial', 'spine', 'constant']),
3626
3749
  cx: z.number().optional(), cy: z.number().optional(),
3627
3750
  angle: z.number().optional(),
3628
- spine: z.array(PositionSchema).optional(),
3751
+ // [x, y] is converted to {x, y} here: the engine's own spine reader only
3752
+ // takes {x, y} and crashed on an array pair when called directly.
3753
+ spine: z.array(PositionSchema).optional().describe('Points along the midrib, each {x, y} or [x, y]'),
3629
3754
  across: z.boolean().optional(),
3630
3755
  }).optional().describe("apply_thread: the direction field — the thing that makes it needlepainting rather than hatching. Default radial from the shape centre. 'spine' runs stitches along a midrib or feather shaft; 'constant' is flat hatch."),
3631
3756
  stitchLen: z.number().positive().optional().describe('apply_thread: nominal stitch length px (default 18).'),
@@ -3669,7 +3794,9 @@ export const DesignMediumInputSchema = z.object({
3669
3794
  })
3670
3795
  .refine((v) => v.action !== 'apply_thread' || !!v.itemId, { message: 'apply_thread requires itemId', path: ['itemId'] })
3671
3796
  .refine((v) => v.action !== 'resolve' || !!v.medium, { message: 'resolve requires medium', path: ['medium'] })
3672
- .refine((v) => v.action !== 'apply_hatch' || !!v.itemId, { message: 'apply_hatch requires itemId', path: ['itemId'] });
3797
+ .refine((v) => v.action !== 'apply_hatch' || !!v.itemId, { message: 'apply_hatch requires itemId', path: ['itemId'] })
3798
+ .refine((v) => v.action !== 'apply' || (!!v.itemId && !!v.medium), { message: 'apply requires itemId and medium', path: ['medium'] })
3799
+ .refine((v) => v.action !== 'apply' || !v.medium || DESIGN_MEDIA.includes(v.medium), { message: `unknown medium; the engine knows ${DESIGN_MEDIA.join(', ')}`, path: ['medium'] });
3673
3800
  export const TextEffectInputSchema = z.object({
3674
3801
  action: z.enum(['apply', 'list'])
3675
3802
  .describe("'apply' (explode a text item into animated characters) · 'list' (the 37 effects with key/label/definition, for a picker)"),
@@ -3791,7 +3918,7 @@ export const World3DInputSchema = z.object({
3791
3918
  .describe("'create' (a preset world) · 'describe' (every parameter with type/range — CALL THIS before configure) · 'configure' (live deep-merge patch, schema-validated) · actor ops · 'set_camera' (follow|fixed|orbit) · object ops · 'remove_world' · MESH AUTHORING: 'extrude_path' / 'lathe_path' turn a canvas path into real geometry, 'list_meshes', 'remove_mesh' · 'add_light' / 'set_light' / 'remove_light' / 'list_lights' (at most 8 point lights) · 'add_material' / 'set_material' / 'remove_material' / 'list_materials'"),
3792
3919
  spec: z.union([z.string(), z.record(z.string(), z.unknown())]).optional().describe("create: a preset id ('forest', 'snowMountain', …) or a full world spec object."),
3793
3920
  character: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional().describe('create: include the walkable character (true/config).'),
3794
- patch: z.record(z.string(), z.unknown()).optional().describe('configure: partial world spec, deep-merged and validated — a wrong key errors naming the right one.'),
3921
+ patch: z.record(z.string(), z.unknown()).optional().describe('configure: partial world spec, deep-merged and validated — a wrong key errors naming the right one. Colours (env.zenith, env.horizon, env.fogColor, env.sunColor, env.ambient, env.lowColor … env.propColorB) take hex ("#03050c") or [r, g, b] in 0..1.'),
3795
3922
  actorId: z.string().optional().describe('Actor id — add_actor (optional, generated if omitted) / remove_actor / set_actor_pose / set_camera target.'),
3796
3923
  x: z.number().optional().describe('World-space x — add_actor / add_object.'),
3797
3924
  z: z.number().optional().describe('World-space z — add_actor / add_object.'),
@@ -3806,8 +3933,12 @@ export const World3DInputSchema = z.object({
3806
3933
  pathId: z.string().optional().describe("extrude_path / lathe_path: the canvas path to turn into geometry. Extrude sweeps it along depth; lathe revolves its profile around the Y axis. The path is unchanged — the mesh is new."),
3807
3934
  mesh: z.object({
3808
3935
  id: z.string().optional().describe('Mesh id (generated if omitted).'),
3809
- depth: z.number().optional().describe('extrude_path: how far to sweep, in world units.'),
3810
- unitsPerPixel: z.number().optional().describe('Canvas pixels → world units. The whole scale relationship between the drawing and the world.'),
3936
+ depth: z.number().optional().describe('extrude_path: how far to sweep, in WORLD units (default 1). Not pixels: the outline is canvas px × unitsPerPixel.'),
3937
+ unitsPerPixel: z.number().optional().describe('Canvas pixels → world units (default 0.01, so a 200 px outline is 2 world units). The whole scale relationship between the drawing and the world.'),
3938
+ // FxTool #49 (7c50c838). Declared here, or z.object() strips them before
3939
+ // the emitter's spread can pass them on (the 5.64 colour bug, again).
3940
+ upright: z.boolean().optional().describe('extrude_path: stand the drawing up (canvas up = world up, extruded along Z) — a sign, a letter, a cut-out. Default false: flat, a floor plan in XZ extruded along Y.'),
3941
+ placement: z.enum(['centered', 'canvas']).optional().describe("extrude_path: 'centered' (default) centres the mesh on x/z with its BASE at y, so it stands on the ground at the point you give. 'canvas' keeps the outline's canvas position (× unitsPerPixel), the older behaviour."),
3811
3942
  caps: z.boolean().optional().describe('extrude_path: close the two ends. A capped extrude is a solid; an uncapped one is a ribbon.'),
3812
3943
  segments: z.number().optional().describe('extrude_path: segments along the sweep · lathe_path: segments around the revolution.'),
3813
3944
  arc: z.number().optional().describe('lathe_path: how far round to revolve, in degrees. 360 is a full solid of revolution; less leaves it open.'),
@@ -3834,7 +3965,7 @@ export const World3DInputSchema = z.object({
3834
3965
  x: z.number().optional().describe('World-space x (default 0).'),
3835
3966
  y: z.number().optional().describe('World-space y (default 3 — above the ground, not on it).'),
3836
3967
  z: z.number().optional().describe('World-space z (default 0).'),
3837
- color: z.array(z.number()).length(3).optional().describe('Linear RGB, each component ≥ 0 (default [1, 0.86, 0.62], a warm lamp).'),
3968
+ color: z.union([z.string(), z.array(z.number()).length(3)]).optional().describe('Hex ("#ffdca0") or linear RGB [r, g, b], each component ≥ 0 and may exceed 1 for a brighter light (default [1, 0.86, 0.62], a warm lamp).'),
3838
3969
  intensity: z.number().optional().describe('Brightness (default 6).'),
3839
3970
  range: z.number().optional().describe('How far the light reaches, in world units (default 20).'),
3840
3971
  }).optional().describe('add_light / set_light: the light spec. At most 8 point lights exist at once — the shader array is fixed-size, so a ninth is refused by name rather than ignored.'),
@@ -3842,14 +3973,14 @@ export const World3DInputSchema = z.object({
3842
3973
  // --- Materials ------------------------------------------------------------
3843
3974
  material: z.object({
3844
3975
  id: z.string().optional().describe('Material id (generated if omitted).'),
3845
- color: z.array(z.number()).length(3).optional().describe('Base colour, linear RGB (default [0.8, 0.8, 0.82]).'),
3846
- emissive: z.array(z.number()).length(3).optional().describe('Light the surface emits regardless of the scene (default [0, 0, 0]).'),
3976
+ color: z.union([z.string(), z.array(z.number()).length(3)]).optional().describe('Base colour: hex ("#e9e4d8") or linear RGB [r, g, b] in 0..1 (default [0.8, 0.8, 0.82]).'),
3977
+ emissive: z.union([z.string(), z.array(z.number()).length(3)]).optional().describe('Light the surface emits regardless of the scene: hex or [r, g, b] in 0..1 (default [0, 0, 0]).'),
3847
3978
  metalness: z.number().min(0).max(1).optional().describe('0 dielectric → 1 metal.'),
3848
3979
  roughness: z.number().min(0).max(1).optional().describe('0 mirror → 1 fully diffuse. Floored at 0.045: a true zero is a numerically unstable mirror.'),
3849
3980
  emissiveIntensity: z.number().min(0).optional().describe('Scales `emissive` before the frame is built, so it reaches every path that draws emissive.'),
3850
3981
  clearcoat: z.number().min(0).max(1).optional().describe('A lacquer layer over the base — car paint, varnished wood.'),
3851
3982
  clearcoatRoughness: z.number().min(0).max(1).optional().describe("The coat's own roughness. NOT floored like the base, so a mirror-smooth lacquer stays expressible."),
3852
- sheenColor: z.array(z.number()).length(3).optional().describe('Retroreflective sheen colour — cloth, velvet.'),
3983
+ sheenColor: z.union([z.string(), z.array(z.number()).length(3)]).optional().describe('Retroreflective sheen colour — cloth, velvet: hex or [r, g, b] in 0..1.'),
3853
3984
  sheenRoughness: z.number().min(0).max(1).optional().describe('How broad the sheen lobe is.'),
3854
3985
  }).optional().describe("add_material / set_material: a NAMED, SHARED surface referenced by many objects, so one edit restyles all of them. metalness and roughness reach the shader on the MESH path only. There are no aoMapIntensity / normalScale / envMapIntensity knobs: each scales a map that does not exist, and a knob that scales nothing is worse than a missing one."),
3855
3986
  materialId: z.string().optional().describe('set_material / remove_material: the material id.'),
@@ -3860,8 +3991,8 @@ export const World3DInputSchema = z.object({
3860
3991
  crossfade: z.number().optional().describe('set_mesh_clip: seconds to blend out of the clip that was playing. The blend happens in LOCAL pose space — a palette cannot be blended after the fact.'),
3861
3992
  // --- Navigation, picking and the camera ----------------------------------
3862
3993
  navTarget: z.enum(['2d', '3d']).optional().describe("set_nav_target: which layer the pointer drives — '2d' the Paper canvas, '3d' the world."),
3863
- point: z.object({ x: z.number(), y: z.number(), z: z.number().optional() }).optional()
3864
- .describe('ground_height / canvas_to_ground / world_to_canvas: the point to convert. ground_height and canvas_to_ground read x and y (canvas) or x and z (world); world_to_canvas takes a world point.'),
3994
+ point: z.object({ x: z.number(), y: z.number().optional(), z: z.number().optional() }).optional()
3995
+ .describe('The point to convert. ground_height: {x, z} in WORLD coordinates (the terrain height there; y is read as z if z is absent). canvas_to_ground: {x, y} in CANVAS pixels. world_to_canvas: {x, y, z?} in world coordinates.'),
3865
3996
  origin: z.array(z.number()).length(3).optional().describe('raycast: ray origin in world space, [x, y, z].'),
3866
3997
  direction: z.array(z.number()).length(3).optional().describe('raycast: ray direction in world space, [x, y, z]. Need not be normalised.'),
3867
3998
  multiplier: z.number().optional().describe('dolly_camera: greater than 1 moves away, less than 1 moves closer.'),
@@ -3879,6 +4010,10 @@ export const World3DInputSchema = z.object({
3879
4010
  .describe('set_mesh_instances: one entry per copy, six numbers each — x, y, z, scale, rotY (RADIANS), and a free variant a shader may read for per-instance colour or phase.'),
3880
4011
  })
3881
4012
  .refine((v) => v.action !== 'configure' || !!v.patch, { message: 'configure requires patch', path: ['patch'] })
4013
+ // ground_height reads a WORLD (x, z); y is accepted in z's place for older callers.
4014
+ .refine((v) => v.action !== 'ground_height' || (!!v.point && (v.point.z !== undefined || v.point.y !== undefined)), { message: 'ground_height requires point {x, z} (world coordinates)', path: ['point'] })
4015
+ .refine((v) => v.action !== 'canvas_to_ground' || (!!v.point && v.point.y !== undefined), { message: 'canvas_to_ground requires point {x, y} (canvas coordinates)', path: ['point'] })
4016
+ .refine((v) => v.action !== 'world_to_canvas' || (!!v.point && v.point.y !== undefined), { message: 'world_to_canvas requires point {x, y, z?} (world coordinates)', path: ['point'] })
3882
4017
  .refine((v) => !['remove_actor', 'set_actor_pose'].includes(v.action) || !!v.actorId, { message: 'this action requires actorId', path: ['actorId'] })
3883
4018
  .refine((v) => v.action !== 'set_actor_pose' || !!v.pose, { message: 'set_actor_pose requires pose', path: ['pose'] })
3884
4019
  .refine((v) => v.action !== 'set_camera' || !!v.camera, { message: 'set_camera requires camera', path: ['camera'] })
@@ -4078,6 +4213,7 @@ export const SoundInputSchema = z.object({
4078
4213
  from: z.number().positive().describe('Starting frequency in Hz.'),
4079
4214
  tau: z.number().positive().max(5).describe('Glide time constant in seconds (max 5).'),
4080
4215
  }).optional().describe("define_percussion / define_sfx: the pitch glide, which is what makes a kick a kick — the engine's own note is that the glide IS the drum. Omit it and a defined kick comes out a beep."),
4216
+ duration: z.number().positive().optional().describe('render_soundtrack: seconds to render (same as options.duration; this wins if both are given).'),
4081
4217
  sampleRate: z.number().int().positive().optional().describe('render_soundtrack: samples per second (default 48000).'),
4082
4218
  bitDepth: z.union([z.literal(16), z.literal(32)]).optional().describe('render_soundtrack: 16 (default) or 32-bit float.'),
4083
4219
  text: z.string().optional().describe("from_text / play_from_text: a plain-language description, e.g. 'a soft warm bell on A4'. 'from_text' resolves it to a spec WITHOUT playing, so a caller can inspect or edit before committing."),
@@ -4119,7 +4255,7 @@ export const SoundInputSchema = z.object({
4119
4255
  .refine((v) => !['define_percussion', 'define_sfx'].includes(v.action)
4120
4256
  || (v.partials?.length ?? 0) > 0 || (v.noise ?? 0) > 0, { message: 'this action needs a partial table or noise > 0 — with neither the sound is silent', path: ['noise'] })
4121
4257
  // Bound by the scene, like every other timed export.
4122
- .refine((v) => v.action !== 'render_soundtrack' || (v.options?.duration ?? 1) <= VIDEO_MAX_DURATION_S, { message: `render_soundtrack duration is capped at ${VIDEO_MAX_DURATION_S}s, the same ceiling as video`, path: ['options'] });
4258
+ .refine((v) => v.action !== 'render_soundtrack' || (v.duration ?? v.options?.duration ?? 1) <= VIDEO_MAX_DURATION_S, { message: `render_soundtrack duration is capped at ${VIDEO_MAX_DURATION_S}s, the same ceiling as video`, path: ['options'] });
4123
4259
  /**
4124
4260
  * pinepaper_motion — the generators' Animation knob, pointed at anything.
4125
4261
  *