@pinepaper.studio/mcp-server 1.6.13 → 1.6.15

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 (94) hide show
  1. package/README.de.md +6 -1
  2. package/README.es.md +6 -1
  3. package/README.fr.md +6 -1
  4. package/README.hi.md +6 -1
  5. package/README.ja.md +6 -1
  6. package/README.ko.md +6 -1
  7. package/README.md +245 -6
  8. package/README.pt-BR.md +6 -1
  9. package/README.zh-CN.md +6 -1
  10. package/assets/brand/pinepaper-lockup-horizontal-white.svg +1 -0
  11. package/assets/brand/pinepaper-lockup-horizontal.svg +1 -0
  12. package/assets/edit-whenever.svg +1 -1
  13. package/dist/browser/puppeteer-controller.js +2 -2
  14. package/dist/browser/puppeteer-controller.js.map +1 -1
  15. package/dist/build-info.json +5 -0
  16. package/dist/design/design-systems.js +1 -1
  17. package/dist/design/design-systems.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/ontology/upstream-vocabulary.d.ts +23 -0
  22. package/dist/ontology/upstream-vocabulary.d.ts.map +1 -0
  23. package/dist/ontology/upstream-vocabulary.js +1172 -0
  24. package/dist/ontology/upstream-vocabulary.js.map +1 -0
  25. package/dist/ontology/vocabulary.d.ts.map +1 -1
  26. package/dist/ontology/vocabulary.js +19 -0
  27. package/dist/ontology/vocabulary.js.map +1 -1
  28. package/dist/tools/definitions.d.ts.map +1 -1
  29. package/dist/tools/definitions.js +264 -70
  30. package/dist/tools/definitions.js.map +1 -1
  31. package/dist/tools/engine-methods.d.ts.map +1 -1
  32. package/dist/tools/engine-methods.js +24 -2
  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 +37 -10
  37. package/dist/tools/engine-surface.js.map +1 -1
  38. package/dist/tools/handlers/camera.d.ts.map +1 -1
  39. package/dist/tools/handlers/camera.js +17 -2
  40. package/dist/tools/handlers/camera.js.map +1 -1
  41. package/dist/tools/handlers/font.d.ts.map +1 -1
  42. package/dist/tools/handlers/font.js +48 -3
  43. package/dist/tools/handlers/font.js.map +1 -1
  44. package/dist/tools/handlers/maps.d.ts.map +1 -1
  45. package/dist/tools/handlers/maps.js +16 -0
  46. package/dist/tools/handlers/maps.js.map +1 -1
  47. package/dist/tools/handlers.d.ts +11 -1
  48. package/dist/tools/handlers.d.ts.map +1 -1
  49. package/dist/tools/handlers.js +426 -14
  50. package/dist/tools/handlers.js.map +1 -1
  51. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  52. package/dist/tools/minimal-descriptions.js +2 -0
  53. package/dist/tools/minimal-descriptions.js.map +1 -1
  54. package/dist/tools/shape-params.d.ts +13 -2
  55. package/dist/tools/shape-params.d.ts.map +1 -1
  56. package/dist/tools/shape-params.js +98 -3
  57. package/dist/tools/shape-params.js.map +1 -1
  58. package/dist/tools/stick-vocabulary.d.ts +1 -1
  59. package/dist/tools/stick-vocabulary.js +1 -1
  60. package/dist/tools/toolkits.d.ts.map +1 -1
  61. package/dist/tools/toolkits.js +2 -0
  62. package/dist/tools/toolkits.js.map +1 -1
  63. package/dist/types/code-generator.d.ts +27 -1
  64. package/dist/types/code-generator.d.ts.map +1 -1
  65. package/dist/types/code-generator.js +2506 -273
  66. package/dist/types/code-generator.js.map +1 -1
  67. package/dist/types/schemas.d.ts +1483 -239
  68. package/dist/types/schemas.d.ts.map +1 -1
  69. package/dist/types/schemas.js +259 -26
  70. package/dist/types/schemas.js.map +1 -1
  71. package/dist/utils/a11y.d.ts +67 -0
  72. package/dist/utils/a11y.d.ts.map +1 -0
  73. package/dist/utils/a11y.js +117 -0
  74. package/dist/utils/a11y.js.map +1 -0
  75. package/dist/utils/ad-package.d.ts +44 -0
  76. package/dist/utils/ad-package.d.ts.map +1 -0
  77. package/dist/utils/ad-package.js +121 -0
  78. package/dist/utils/ad-package.js.map +1 -0
  79. package/dist/utils/scc.d.ts +41 -0
  80. package/dist/utils/scc.d.ts.map +1 -0
  81. package/dist/utils/scc.js +185 -0
  82. package/dist/utils/scc.js.map +1 -0
  83. package/dist/utils/zip.d.ts +7 -0
  84. package/dist/utils/zip.d.ts.map +1 -0
  85. package/dist/utils/zip.js +82 -0
  86. package/dist/utils/zip.js.map +1 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.d.ts.map +1 -1
  89. package/dist/version.js +31 -1
  90. package/dist/version.js.map +1 -1
  91. package/icon.png +0 -0
  92. package/icon.svg +1 -0
  93. package/manifest.json +11 -2
  94. package/package.json +8 -4
@@ -11,7 +11,7 @@ import { COMPACT_DESCRIPTIONS } from './compact-descriptions.js';
11
11
  import { MINIMAL_DESCRIPTIONS } from './minimal-descriptions.js';
12
12
  import { STICK_GAITS, STICK_POSES, STICK_SEQUENCES, STICK_EXPRESSIONS, STICK_PROPS } from './stick-vocabulary.js';
13
13
  import { GeneratorNameSchema, ItemTypeSchema } from '../types/schemas.js';
14
- import { EffectTypeSchema, SimpleAnimationTypeSchema, GEOMETRY_OPERATIONS, THREAD_STITCHES, } from '../types/schemas.js';
14
+ import { EffectTypeSchema, SimpleAnimationTypeSchema, EasingSchema, GEOMETRY_OPERATIONS, THREAD_STITCHES, } from '../types/schemas.js';
15
15
  /**
16
16
  * Vocabulary lists derived from Zod schemas so tool descriptions stay in
17
17
  * sync automatically. The description-schema-parity test
@@ -21,6 +21,10 @@ import { EffectTypeSchema, SimpleAnimationTypeSchema, GEOMETRY_OPERATIONS, THREA
21
21
  */
22
22
  const EFFECT_TYPES_LIST = EffectTypeSchema.options.join(', ');
23
23
  const SIMPLE_ANIMATION_TYPES_LIST = SimpleAnimationTypeSchema.options.join(', ');
24
+ // The easings a keyframe accepts. The batch description once listed
25
+ // easeInCubic / easeOutCubic / easeOutBounce, which keyframe_animate rejects
26
+ // (round 8 DD, 1.68) — derived now, so it cannot say what the schema refuses.
27
+ const KEYFRAME_EASINGS_LIST = EasingSchema.options.join(', ');
24
28
  // =============================================================================
25
29
  // AI AGENT GUIDE (exported for pinepaper_tool_guide)
26
30
  // =============================================================================
@@ -257,12 +261,12 @@ COMMON PRESETS:
257
261
  - hd-landscape: 1920x1080
258
262
  - hd-portrait: 1080x1920
259
263
 
260
- Max canvas size: 4096x4096. For wedding invitations, event cards, or detailed designs, use at least 1080x1080 or larger.`,
264
+ Max canvas size: 8192x8192 (a 5400x1350 carousel panorama fits). For wedding invitations, event cards, or detailed designs, use at least 1080x1080 or larger.`,
261
265
  inputSchema: {
262
266
  type: 'object',
263
267
  properties: {
264
- width: { type: 'number', description: 'Canvas width in pixels (100-4096)' },
265
- height: { type: 'number', description: 'Canvas height in pixels (100-4096)' },
268
+ width: { type: 'number', description: 'Canvas width in pixels (100-8192)' },
269
+ height: { type: 'number', description: 'Canvas height in pixels (100-8192)' },
266
270
  preset: {
267
271
  type: 'string',
268
272
  description: 'Optional preset name',
@@ -400,6 +404,10 @@ its own size. anchor: 'top-left' (also top-right, bottom-left, bottom-right,
400
404
  center; origin is an alias for the parameter) fixes that without you computing
401
405
  bounds you cannot know until the item exists. It is applied after size and
402
406
  rotation, so it uses the item's real box.
407
+ PIXEL-EXACT SLOTS (a spec'd 615x345 end-screen box, a UI frame): use anchor
408
+ 'top-left' with WHOLE-NUMBER x, y, width and height. A centred box with an odd
409
+ width or height has its edges on half pixels, which renders 1 px short with a
410
+ soft anti-aliased edge (measured: 615x345 centred came out 614x344).
403
411
 
404
412
  EVERY type below also takes a label, which wraps the item in a labelled group —
405
413
  you do not need a second create call and a hand-computed position to caption a
@@ -413,7 +421,12 @@ Labelling a text item gives you TWO text items in the group — the body and the
413
421
  label. That is what you want for a caption; it is a surprise if you expected
414
422
  the label to replace the content.
415
423
 
416
- - text: Text content with font styling (properties: content, fontSize, color, fontFamily)
424
+ - text: Text content with font styling (properties: content, fontSize, color, fontFamily, justification, fontWeight, fontStyle, leading, lineHeight)
425
+ fontWeight: 400 / 700 / 'bold'. fontStyle: 'italic'. leading: line spacing in px; lineHeight: a multiple of fontSize (<= 4) or px. Multi-line content splits on \n.
426
+ fit: {maxWidth, maxHeight?, minFontSize? (8), maxFontSize? (current), wrap? (true), hold? ('top' | 'center' | …)} — auto-size the text into a box, keeping its anchored edge. The box is remembered: changing content later refits (shrinks, or grows back to maxFontSize); modify_item {fit: {...}} sets a new box; {fit: null} stops fitting. The result's textFit says {fontSize, lines, fits}; fits:false means it overflows even at minFontSize. Studios without auto-fit say so in textFit. Every export's fidelity then lists text_overflow for any text past its box or off the frame — the check to run before a batch of variants.
427
+ direction: 'auto' (default: the first strong letter decides, so Arabic and Hebrew need nothing) | 'ltr' | 'rtl' — the paragraph's base direction, which places neutrals like the final period (aliases textDirection, dir). The result's direction says whether the studio applied it; a studio without RTL support lays text out left-to-right and says so. justification: 'right' is alignment, not direction.
428
+ tabularFigures: true (or fontVariantNumeric: 'tabular-nums', or fontFeatures containing 'tnum') — fixed-width digits, so a counter, price or timer does not jitter as it changes. The result's tabularFigures says whether the studio applied them.
429
+ strokePosition: 'outside' — the stroke sits entirely outside the letters (drawn under the fill at twice strokeWidth), so a thick caption / meme outline keeps the letterform; 'center' (default) is the ordinary stroke. The result's strokePosition says whether the studio applied it.
417
430
  - circle: Circular shape (properties: radius, color, strokeColor, strokeWidth)
418
431
  - star: Star shape (properties: radius1, radius2, points, color)
419
432
  radius1 is the OUTER radius and is what sizes the star; radius or
@@ -450,6 +463,7 @@ the label to replace the content.
450
463
  positioned it. Offset the points yourself, or use sides + radius + position.
451
464
  - ellipse: Oval shape (properties: width, height, color) — defaults to 100x60
452
465
  - path: Custom path with segments or SVG data (properties: segments, pathData, strokeColor, fillColor, closed, smooth)
466
+ color on a path: a CLOSED path (closed:true, or pathData ending in Z) is FILLED with it; an OPEN path is STROKED with it. Pass fillColor / strokeColor to say exactly which.
453
467
  - line: Line between two points (properties: from, to, strokeColor, strokeWidth)
454
468
  - arc: Curved arc through three points (properties: from, through, to, strokeColor)
455
469
  - pentagon: Regular 5-sided polygon (properties: radius, color)
@@ -496,6 +510,7 @@ Use itemType: "path" with either:
496
510
 
497
511
  ADVANCED STYLING (NEW):
498
512
  All shapes support these additional properties:
513
+ - No fill (stroke-only outlines, line art): fillColor: null, 'transparent' or 'none', with a strokeColor and strokeWidth.
499
514
  - Gradients: color can be an object: {type: "linear"|"radial", stops: [{color: "#fff", offset: 0}, {color: "#000", offset: 1}], origin: [x,y], destination: [x,y]}
500
515
  - Shadows: shadowColor, shadowBlur (pixels), shadowOffset: [x, y]
501
516
  - Blend modes: blendMode: "multiply"|"screen"|"overlay"|"darken"|"lighten"|etc.
@@ -574,6 +589,11 @@ LIFETIME — on ANY item, and the only way to build a piece that CUTS between sh
574
589
  - ttl: seconds it lives for. Omit for "until the end".
575
590
  Without these every item is on screen for the whole render, which is what makes
576
591
  a long piece read as one crowded frame instead of a sequence of shots.
592
+ They set the item's VISIBILITY for [bornAt, bornAt + ttl) — not its opacity —
593
+ so they compose with any keyframes, opacity fades included. Re-timing with
594
+ pinepaper_modify_item {bornAt, ttl} replaces the previous lifetime — pass both.
595
+ The result's lifetime field says whether it was applied; it is runtime-only
596
+ until the studio reads bornAt / ttl itself, so re-time a reopened scene.
577
597
 
578
598
  SHADER (itemType 'shader') — a lit surface, drawn per pixel:
579
599
  - shader: 'water' (open sea, lit swell) | 'liquid_metal' (chrome) | 'heatmap' (thermal halo) | 'gem_smoke' (smoke) | 'electric_arc' (branching discharge, stepped flicker) | 'vortex' (hollow bright RING, sheared by differential rotation) | 'rain_veil' | 'caustics' | 'ink_bleed'
@@ -596,6 +616,11 @@ thousands of authored items.
596
616
  - Example — a rolling swell: xExpr "(i % 120) * 8", yExpr "400 + sin((i % 120) * 0.4 - t * 2) * 12", count 3600`,
597
617
  additionalProperties: true,
598
618
  },
619
+ anchor: {
620
+ type: 'string',
621
+ enum: ['center', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
622
+ description: "Which point of the item `position` names (default center). Same as properties.anchor — for a pixel-exact slot use 'top-left' with whole numbers.",
623
+ },
599
624
  data: {
600
625
  type: 'object',
601
626
  description: 'Item data flags for selection and behavior control',
@@ -668,6 +693,7 @@ MODIFIABLE PROPERTIES:
668
693
  - content: Text content
669
694
  - opacity: Transparency (0-1)
670
695
  - rotation: Rotation in degrees
696
+ - skewX / skewY: shear in degrees about the item's centre; matrix: [a, b, c, d, tx, ty] affine. For mockups (a box face, a screen at an angle). APPLIED to the current shape, so calling again compounds — the result's affine says so.
671
697
  - scale: Size multiplier
672
698
  - shadowColor: Shadow color
673
699
  - shadowBlur: Shadow blur radius
@@ -966,6 +992,8 @@ USE WHEN:
966
992
  - Adding logos or vector graphics
967
993
  - User mentions SVG files or vector graphics
968
994
 
995
+ CHECK LINE-ART IMPORTS: fill="none" and stroke-dasharray have been measured to be lost on import — outline icons come in filled, dashed strokes come in solid. For line art, check the result with a screenshot; to fix it, set fillColor: null (and dashArray) on the imported paths with pinepaper_modify_item.
996
+
969
997
  ⚠️ RECOMMENDED FOR COMPLEX CHARACTERS:
970
998
  Instead of drawing "a witch" or "a cat" with many path operations, use SVG import:
971
999
  1. Describe what you need and use a simple placeholder SVG
@@ -1129,6 +1157,11 @@ EXAMPLES:
1129
1157
  enum: ['circle', 'rounded', 'hexagon', 'star'],
1130
1158
  description: 'Optional mask shape to apply to the image',
1131
1159
  },
1160
+ smoothing: {
1161
+ type: 'string',
1162
+ enum: ['off', 'low', 'medium', 'high'],
1163
+ description: "Resampling when the image is scaled. 'off' = nearest-neighbour, hard pixel edges — use it for pixel art. Default 'low'. Change it later with pinepaper_modify_item {smoothing}.",
1164
+ },
1132
1165
  },
1133
1166
  required: ['url'],
1134
1167
  },
@@ -1539,7 +1572,7 @@ warp (optional): chained parametric warp(s) — displace each sampled point by {
1539
1572
 
1540
1573
  RETURNS: { success, kind, itemId } — itemId is the created path. Bad expressions / empty ranges return { success:false, error }.
1541
1574
 
1542
- To make an item TRAVEL along an equation curve (instead of drawing it), use pinepaper_add_relation relationType moves_along_path with params.equation instead.
1575
+ To make an item TRAVEL along an equation curve (instead of drawing it), use pinepaper_add_relation relationType moves_along_path with params.equation — but note that equation is read ONLY on the relation's signal path. An option that leaves that path takes the equation with it and the item simply does not move: easing 'bounce' is piecewise and falls back, so bounce + equation is a static item reporting success. Use a signal-friendly easing (linear, easeIn, easeOut, easeInOut, sine, pingpong) with an equation, or supply params.path instead.
1543
1576
 
1544
1577
  EXAMPLES:
1545
1578
  - Sine wave: { kind: "function", expr: "sin(x/40) * 60", min: -300, max: 300, style: { strokeColor: "#4f46e5" } }
@@ -1564,7 +1597,7 @@ EXAMPLES:
1564
1597
  y: { type: 'number', description: 'Canvas origin y (default: canvas center)' },
1565
1598
  flipY: { type: 'boolean', description: 'Math y-up → screen y-down (default true)' },
1566
1599
  style: { type: 'object', description: 'Path style forwarded to create (strokeColor, strokeWidth, fillColor, …)' },
1567
- solveOde: { type: 'object', description: "Integrate an ODE instead of plotting a closed form, and return the SOLUTION rather than drawing it: { equations, initialState, tEnd?, dt?, method? ('rk4' default, or 'euler' — faster and visibly wrong on anything stiff) }. The trajectory comes back as data, to inspect, feed to a path, or drive keyframes with." },
1600
+ solveOde: { type: 'object', description: "Integrate an ODE instead of plotting a closed form, and return the SOLUTION rather than drawing it: { equations, initialState, tEnd?, dt?, method? ('rk4' default, or 'euler' — faster and visibly wrong on anything stiff) }. STATE VARIABLES ARE POSITIONAL: each expression is written in terms of t, y0, y1, y2 ... where yN is the Nth entry of initialState. There is no x or y, so a Lorenz system is ['10*(y1-y0)', 'y0*(28-y2)-y1', 'y0*y1-(8/3)*y2'] with initialState [1,1,1] — writing it as '10*(y-x)' fails with \"Undefined symbol y\". The trajectory comes back as data, to inspect, feed to a path, or drive keyframes with." },
1568
1601
  warp: {
1569
1602
  anyOf: [{ type: 'object' }, { type: 'array', items: { type: 'object' } }],
1570
1603
  description: 'Chained parametric warp(s): { dx, dy } expressions of x, y, t — one object or an array',
@@ -1696,6 +1729,75 @@ EXAMPLES:
1696
1729
  required: ['action'],
1697
1730
  },
1698
1731
  },
1732
+ {
1733
+ name: 'pinepaper_accessibility_check',
1734
+ annotations: {
1735
+ title: 'Accessibility Check',
1736
+ readOnlyHint: true,
1737
+ destructiveHint: false,
1738
+ idempotentHint: true,
1739
+ openWorldHint: false,
1740
+ },
1741
+ description: `Check the live scene for two accessibility failures that stop ads and broadcast spots: low text contrast and flashing.
1742
+
1743
+ - contrast: WCAG 1.4.3 AA — 4.5:1, or 3:1 for large text (24 px, or 18.66 px bold). Each text's fill against the solid fill of the topmost item under it, or the page background. Text over a photo or gradient is skipped and listed.
1744
+ - flash: WCAG 2.3.1 general flash — samples the timeline (duration, fps) and fails any region of the frame with more than 3 flashes in a second. It seeks deterministically, so playback is not disturbed.
1745
+
1746
+ Both are heuristic screens and say so: not a certified photosensitive-epilepsy (PSE) test, and red flashes are not checked. Needs a live studio for verdicts; in code-only mode it returns the measuring code.
1747
+
1748
+ EXAMPLE: { checks: ["contrast", "flash"], duration: 6, fps: 20 }`,
1749
+ inputSchema: {
1750
+ type: 'object',
1751
+ properties: {
1752
+ checks: { type: 'array', items: { type: 'string', enum: ['contrast', 'flash'] }, description: 'Which checks (default both).' },
1753
+ duration: { type: 'number', exclusiveMinimum: 0, maximum: 120, description: 'flash: seconds of the timeline to sample (default 5).' },
1754
+ fps: { type: 'integer', minimum: 10, maximum: 60, description: 'flash: samples per second (default 20).' },
1755
+ },
1756
+ },
1757
+ },
1758
+ {
1759
+ name: 'pinepaper_render_batch',
1760
+ annotations: {
1761
+ title: 'Render Batch',
1762
+ readOnlyHint: false,
1763
+ destructiveHint: false,
1764
+ idempotentHint: false,
1765
+ openWorldHint: false,
1766
+ },
1767
+ description: `Render one creative per row of data from the current scene: for each row, apply its changes (modify_item per item, and / or a template's params), then export with the shared export options. For variants, translations and dynamic creative (DCO).
1768
+
1769
+ Each row runs through the same tools you would call yourself, so every check applies per row: unread properties, text fit, export fidelity. A row that fails is reported by index and the batch continues.
1770
+
1771
+ ROWS DO NOT RESET: row 2 starts from the scene row 1 left. Give every row every field that varies (all headlines, all prices), not only what differs from the row before. Pair with text fit so longer copy still fits its box, and read each row's fidelity for text_overflow.
1772
+
1773
+ Needs a live studio (it renders); in code-only mode it is refused. Max 100 rows; estimateOnly checks the rows first.
1774
+
1775
+ EXAMPLE:
1776
+ { rows: [
1777
+ { id: "en", changes: { headline: { content: "Summer Sale" }, price: { content: "$19" } } },
1778
+ { id: "de", changes: { headline: { content: "Sommerschlussverkauf" }, price: { content: "19 €" } } } ],
1779
+ export: { format: "png", platform: "instagram" } }`,
1780
+ inputSchema: {
1781
+ type: 'object',
1782
+ properties: {
1783
+ rows: {
1784
+ type: 'array', minItems: 1, maxItems: 100,
1785
+ description: 'One entry per creative.',
1786
+ items: {
1787
+ type: 'object',
1788
+ properties: {
1789
+ id: { type: 'string', description: 'A name for the row, echoed in its result.' },
1790
+ changes: { type: 'object', additionalProperties: { type: 'object' }, description: 'itemId → properties (as modify_item takes them).' },
1791
+ template: { type: 'object', properties: { templateId: { type: 'string' }, params: { type: 'object' } }, required: ['templateId', 'params'], description: 'template_params apply for this row.' },
1792
+ },
1793
+ },
1794
+ },
1795
+ export: { type: 'object', description: 'pinepaper_agent_export options for every row (format, platform, quality, duration, ad, …).' },
1796
+ estimateOnly: { type: 'boolean', description: 'Validate and return the plan without rendering.' },
1797
+ },
1798
+ required: ['rows', 'export'],
1799
+ },
1800
+ },
1699
1801
  {
1700
1802
  name: 'pinepaper_camera_director',
1701
1803
  annotations: {
@@ -1942,7 +2044,7 @@ EXAMPLE: { times: [0, 0.5, 1, 1.5, 2], seed: 42 }`,
1942
2044
 
1943
2045
  ACTIONS:
1944
2046
  - upload_video: { url (required), position?, scale?, timeOffset? (timeline start, s), clipInPoint?, clipOutPoint? (trim, s) } → { id, duration, width, height, name }
1945
- - upload_audio: { url (required), volume? (0–1, default 1), loop? (default true), muted?, timeOffset? } → { id, duration, name }
2047
+ - upload_audio: { url (required), volume? (0–1, default 1), loop?, muted?, timeOffset? } → { id, registryId, duration, name }. Pass loop EXPLICITLY — loop:true for a music bed, loop:false for a voice-over or sting; when omitted, the studio's own default applies and differs between builds.
1946
2048
  - list: → all media [{ id, kind, duration, … }]
1947
2049
  - remove: { id } → removed boolean
1948
2050
  - set_playback_rate: { id, rate (0.25–4) } → applies to video or audio
@@ -1980,7 +2082,7 @@ EXAMPLE: { action: 'upload_video', url: 'https://…/clip.mp4', scale: 0.5, time
1980
2082
  strength: { type: 'number', description: 'apply_track_matte: 0–1.' },
1981
2083
  hideMatte: { type: 'boolean', description: 'apply_track_matte: hide the matte item after applying.' },
1982
2084
  live: { type: 'boolean', description: 'apply_track_matte: keep re-cutting as the matte moves/animates — the kinetic mask reveal. Omitted = bake once, destructively.' },
1983
- url: { type: 'string', description: 'Media URL — required for upload_video / upload_audio.' },
2085
+ url: { type: 'string', description: "Required for upload_video / upload_audio: an http(s) URL, a data: URL, or a PATH TO A LOCAL FILE. Remote URLs and local files are fetched/read by the server (the page may not fetch third-party hosts), so stock music and SFX links work; the HTTP status or content-type is named if one does not. Up to 48 MB." },
1984
2086
  id: { type: 'string', description: 'Media id — required for remove / set_playback_rate / set_clip.' },
1985
2087
  rate: { type: 'number', description: 'Playback rate 0.25–4 — set_playback_rate.' },
1986
2088
  inPoint: { type: 'number', description: 'Clip in-point (media-time s) — set_clip.' },
@@ -1991,7 +2093,7 @@ EXAMPLE: { action: 'upload_video', url: 'https://…/clip.mp4', scale: 0.5, time
1991
2093
  clipInPoint: { type: 'number', description: 'Trim in-point (s) — upload_video.' },
1992
2094
  clipOutPoint: { type: 'number', description: 'Trim out-point (s) — upload_video.' },
1993
2095
  volume: { type: 'number', description: 'Volume 0–1 (default 1) — upload_audio.' },
1994
- loop: { type: 'boolean', description: 'Loop (default true) — upload_audio.' },
2096
+ loop: { type: 'boolean', description: 'upload_audio: true = loop (music beds), false = play once (voice-over, stings). Pass it explicitly; the default when omitted is the studio\'s and differs between builds.' },
1995
2097
  muted: { type: 'boolean', description: 'Start muted — upload_audio.' },
1996
2098
  },
1997
2099
  required: ['action'],
@@ -2300,9 +2402,9 @@ RECIPE — assemble-from-pieces: shatter with keepSource, keyframe each tile fro
2300
2402
 
2301
2403
  Input is the output of a single-image layer decomposer (See-through — one flat anime drawing in, ~20 occlusion-inpainted layers out — or any PSD-style layer dump with the same manifest shape). The decomposition model does NOT run here: it needs a GPU and minutes per image; run it where it fits and import the folder.
2302
2404
 
2303
- Layer tags map onto the exact role tokens the expresses presets read (eye_left, pupil_right, mouth…), so pinepaper_animate/expresses blink-smile-talk work on the imported character immediately.
2405
+ Layer tags map onto the exact role tokens the expresses presets read (eye_left, pupil_right, mouth…), so blink / smile / talk work on the imported character immediately — through an 'expresses' RELATION (pinepaper_add_relation relationType expresses), which is the only thing that drives them. pinepaper_animate applies ambient motion (bounce, pulse, wobble) and cannot blink: asking it to is a silent no-op.
2304
2406
 
2305
- Params: { info (the decomposer manifest: tags, xyxy rects, depth_median draw order, frame_size [h,w]), images ({tag: PNG data URL or CORS-reachable https URL} — data URLs are the reliable path; ~20 layers of base64 is a big call, use URLs when you can), position?, scale?, name? }
2407
+ Params: { info — the decomposer manifest, whose parts live under info.parts (or info.tag2pinfo) as an OBJECT KEYED BY TAG, not a layers[] array: { eye_left: { xyxy: [x0,y0,x1,y1], depth_median: <draw order> }, … } plus frame_size [h,w]. An array here imports nothing and reports no parts. images ({tag: PNG data URL or CORS-reachable https URL} — data URLs are the reliable path; ~20 layers of base64 is a big call, use URLs when you can), position?, scale?, name? }
2306
2408
  Returns: { groupId, parts, roles, rolesWired, warnings }
2307
2409
 
2308
2410
  CHECK rolesWired: a character importing with 0 roles renders perfectly and silently refuses to animate — that number is the difference between a picture and a puppet.`,
@@ -2486,7 +2588,7 @@ Three of these refuse quietly in the engine: exportGLB with no perspective objec
2486
2588
  type: 'object',
2487
2589
  properties: {
2488
2590
  action: { type: 'string', enum: ['export_lottie', 'export_dotlottie', 'import_lottie', 'export_glb', 'export_bvh', 'export_png_sequence'], description: 'Which interchange operation.' },
2489
- options: { type: 'object', description: 'Format options passed through to the exporter: frame range, dimensions, fps, naming.' },
2591
+ options: { type: 'object', description: "Format options passed through to the exporter. export_png_sequence: { duration (seconds), fps, width, height, transparent (keep alpha; default false fills the background) } — PASS duration and fps: the studio does not size the sequence to the scene, and without them it writes its own default length, which does not follow the scene (runs have measured 45 and 90 frames). The result's defaultsUsed says when that happened." },
2490
2592
  data: { anyOf: [{ type: 'string' }, { type: 'object' }], description: 'import_lottie: the Lottie JSON — object, JSON string, or URL.' },
2491
2593
  skeletonId: { type: 'string', description: "export_bvh: which skeleton to write. A BVH is one skeleton's motion." },
2492
2594
  },
@@ -2518,7 +2620,8 @@ render_soundtrack mixes every placed sound to a WAV and writes it to a file. It
2518
2620
  inputSchema: {
2519
2621
  type: 'object',
2520
2622
  properties: {
2521
- action: { type: 'string', enum: ['list_instruments', 'list_percussion', 'list_sfx', 'play_tone', 'play_chord', 'chord_frequencies', 'play_percussion', 'play_sfx', 'play_spec', 'from_text', 'play_from_text', 'create', 'timbre_from_path', 'set_placement', 'remove', 'stop_all', 'define_instrument', 'define_percussion', 'define_sfx', 'render_soundtrack'], description: 'Which sound operation.' },
2623
+ action: { type: 'string', enum: ['list_instruments', 'list_percussion', 'list_sfx', 'play_tone', 'play_chord', 'chord_frequencies', 'play_percussion', 'play_sfx', 'play_spec', 'from_text', 'play_from_text', 'create', 'sequence', 'timbre_from_path', 'set_placement', 'remove', 'stop_all', 'define_instrument', 'define_percussion', 'define_sfx', 'render_soundtrack'], description: 'Which sound operation.' },
2624
+ cues: { type: 'array', description: "sequence: every cue in ONE call — [{t, spec|preset, note?, duration?, visual?}]. Each is created and placed at its own t, so a music bed is one call rather than one per cue. Failures come back by index in failed[], because a bed where three of a hundred cues did not sound is neither a success nor a failure.", items: { type: 'object' } },
2522
2625
  partials: { type: 'array', items: { type: 'object' }, description: 'define_*: [{ h, amp }] — the harmonic table that IS the timbre. Required for define_instrument.' },
2523
2626
  envelope: { type: 'object', description: 'define_*: { attack, decay, sustain, release } — seconds, except sustain which is a level 0-1.' },
2524
2627
  gain: { type: 'number', description: 'define_*: output level 0-1 (0.8 instrument, 0.85 percussion, 0.65 sfx).' },
@@ -2650,7 +2753,7 @@ RECIPE — a character walking through a forest: create {spec:'forest'} → impo
2650
2753
  camera: { type: 'object', description: "set_camera: { mode: follow|fixed|orbit, target?, radius?, speed?, eye?, lookAt? }." },
2651
2754
  object: { type: 'object', description: 'add_object: { x, z, height?, color?, y? }. metalness/roughness are accepted and IGNORED here — a plain object is drawn by the prop shader, which declares no such uniform. They render on the mesh path only.' },
2652
2755
  pathId: { type: 'string', description: 'extrude_path / lathe_path: the canvas path to turn into geometry. The path itself is unchanged.' },
2653
- mesh: { type: 'object', description: 'extrude_path / lathe_path options: { id?, depth?, unitsPerPixel?, caps?, segments?, arc?, flatness?, x?, y?, z?, rotY?, rotYDegrees?, scale?, doubleSided? }. arc is DEGREES; rotY is RADIANS (fed straight to cos/sin in the shader) — pass rotYDegrees to write degrees. These names are the ones the engine records as the mesh provenance, so a captured meshProvenance.opts spreads straight back in.' },
2756
+ mesh: { type: 'object', description: 'extrude_path / lathe_path options: { id?, depth?, unitsPerPixel?, caps?, segments?, arc?, flatness?, x?, y?, z?, rotY?, rotYDegrees?, scale?, doubleSided?, color?, metalness?, roughness? }. color is hex or [r,g,b] (default light grey — without it the mesh rendered black); it becomes the shader\'s diffuse uniform. arc is DEGREES; rotY is RADIANS (fed straight to cos/sin in the shader) — pass rotYDegrees to write degrees. These names are the ones the engine records as the mesh provenance, so a captured meshProvenance.opts spreads straight back in.' },
2654
2757
  meshId: { type: 'string', description: 'remove_mesh: the mesh id.' },
2655
2758
  light: { type: 'object', description: 'add_light / set_light: { id?, x?, y?, z?, color?, intensity?, range? }. At most 8 point lights; the ninth is refused by name because the shader array is fixed-size.' },
2656
2759
  lightId: { type: 'string', description: 'set_light / remove_light: the light id.' },
@@ -2951,6 +3054,11 @@ PARAMS: { eps? (bounds-match tolerance px, default 4), cap? (max suggestions, de
2951
3054
  },
2952
3055
  description: `Create a behavior relationship between two items. Relations are the PRIMARY way to add animation in PinePaper - they describe HOW items should behave relative to each other.
2953
3056
 
3057
+ MOVES_ALONG_PATH — ride a path (a hand tracing a stroke, a car on a road):
3058
+ - sourceId: the item that moves. The path comes from ONE of: targetId (or params.pathId) naming a PATH ITEM, sampled into points for you; params.path as points [[x, y], …]; or params.equation.
3059
+ - params.duration: seconds for one traversal (or pass speed directly, 1 ≈ 150 px/s). params.delay: seconds on the timeline before it starts; until then the item waits at the path start. params.offset: [dx, dy] added to the point on the path — set it to (centre - pen tip) so a hand image's TIP, not its centre, rides the stroke. closed (loop back to the start; default from the path item), phase (0–1 start point), easing (linear | easeIn | easeOut | easeInOut | sine | bounce | pingpong), signal (deterministic, frame-rate-invariant — prefer it for export).
3060
+ - With no path from any of those it is refused, not accepted as a still item.
3061
+
2954
3062
  USE WHEN:
2955
3063
  - "moon orbits earth" → relationType: orbits
2956
3064
  - "label follows player" → relationType: follows
@@ -3016,7 +3124,7 @@ RELATION COMPATIBILITY:
3016
3124
  Text→Text: font blend
3017
3125
  Cross-type: particle denoising transition
3018
3126
  - group_morphs_to: pair-by-index morph between two paper.Groups (any two — graph vertices+edges, letter collages, dashboard clusters). Path.Line children deform via endpoints; other children translate; excess children fade. Params: duration, hold, loop, easing (linear|easeIn|easeOut|easeInOut), deformLines.
3019
- - moves_along_path: self-relation (targetId=null); item is driven along a path. Source the path EITHER from params.path (array of {x,y} or [x,y]) OR from params.equation (S10 B5) — a math curve the item TRAVERSES, sampled deterministically on the Expression IR: params.equation = { kind: function|parametric|fourier|preset, expr | xExpr/yExpr | harmonics | preset, min, max, samples, scale, cx, cy, flipY, warp } (same equation contract as pinepaper_equation_path, but origin is cx/cy, default 0). Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).
3127
+ - moves_along_path: self-relation (targetId=null); item is driven along a path. Source the path EITHER from params.path (array of {x,y} or [x,y]) OR from params.equation (S10 B5) — a math curve the item TRAVERSES, sampled deterministically on the Expression IR: params.equation = { kind: function|parametric|fourier|preset, expr | xExpr/yExpr | harmonics | preset, min, max, samples, scale, cx, cy, flipY, warp } (same equation contract as pinepaper_equation_path, but origin is cx/cy, default 0). READ ONLY ON THE SIGNAL PATH: easing 'bounce' is piecewise and leaves it, which silently discards the equation and leaves the item where it started. Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).
3020
3128
 
3021
3129
  GEOMETRIC CONSTRUCTION CONSTRAINTS (Layer 2) — the source item is RE-DERIVED every frame from its anchor item(s), so dragging an anchor updates the dependent live (GeoGebra-style), and the construction persists as relation graph data (edit it by editing relations, not by re-running code). Anchor A is the relation target; extra anchors ride in params:
3022
3130
  - is_midpoint_of: source = midpoint(target, params.other). params.other = id of the second endpoint (B).
@@ -3394,9 +3502,13 @@ ANIMATABLE PROPERTIES:
3394
3502
  - opacity: 0-1
3395
3503
  - fillColor: Color string
3396
3504
  - strokeColor: Color string
3505
+ - strokeWidth, width, height, radius, shadowBlur, shadowColor: Numbers / colours
3397
3506
  - fontSize: Number
3507
+ - STROKE DRAW-ON (paths): trimStart, trimEnd (0-1 of the path's length), trimOffset (shifts the visible window along it), dashArray. A draw-on is trimEnd 0 -> 1.
3508
+ The authoritative list for a given item is pinepaper_get_animatable_properties — ask it rather than guessing.
3398
3509
 
3399
- EASING OPTIONS:
3510
+ EASING — IT SHAPES THE SEGMENT ARRIVING AT THE KEYFRAME IT IS ON.
3511
+ The move from keyframe A to keyframe B is eased by B's easing, not A's. Easing on the FIRST keyframe does nothing (no segment arrives at it) — a Ken Burns with easing only on its first key plays linear. Put the easing on the key you are moving TO.
3400
3512
  - linear: Constant speed
3401
3513
  - easeIn: Slow start
3402
3514
  - easeOut: Slow end
@@ -3432,6 +3544,7 @@ EXAMPLE — Reveal at 2s, skip first second of keyframe data, end at 4s of keyfr
3432
3544
  easing: {
3433
3545
  type: 'string',
3434
3546
  enum: ['linear', 'easeIn', 'easeOut', 'easeInOut', 'bounce', 'elastic'],
3547
+ description: 'Shapes the segment ARRIVING at this keyframe (from the previous one). Ignored on the first keyframe.',
3435
3548
  },
3436
3549
  },
3437
3550
  required: ['time', 'properties'],
@@ -3639,7 +3752,9 @@ USE WHEN:
3639
3752
  - Creating complex reveal sequences
3640
3753
  - Building custom transitions
3641
3754
 
3642
- Requires maskType and keyframes array.`,
3755
+ Requires maskType and keyframes array.
3756
+
3757
+ COORDINATES: a 'rectangle' mask's x / y are its TOP-LEFT corner (measured: {x:540, y:620, width:800, height:800} reads back as bounds [540, 620, 800, 800]) — unlike items, which are placed by their centre. Convert before masking an item you placed by centre.`,
3643
3758
  inputSchema: {
3644
3759
  type: 'object',
3645
3760
  properties: {
@@ -3776,7 +3891,6 @@ USE WHEN:
3776
3891
  - Building presentation zoom sequences
3777
3892
  - Making dramatic reveals with camera motion
3778
3893
  - Tour animations showing different parts of canvas
3779
- - 3D perspective effects (pitch/yaw tilt via equirectangular projection)
3780
3894
 
3781
3895
  The camera follows a keyframe sequence. For an orbit-style camera (revolving
3782
3896
  around a subject), add 4-8 keyframes around the pivot using pathMode: "arc"
@@ -3791,8 +3905,7 @@ KEYFRAME PROPERTIES:
3791
3905
  'item-id' itemRegistry lookup → item bounds center (tracks the item as it moves)
3792
3906
  { item: 'id', offset: [dx, dy] } item center + offset
3793
3907
  - center: [x, y] — legacy alias, honored only when focus is absent
3794
- - pitch: 3D tilt in degrees (0=flat, positive=tilt forward). Uses equirectangular projection
3795
- - yaw: 3D rotation in degrees. Uses equirectangular projection
3908
+ - pitch / yaw: 3D tilt in degrees. Rendered by studio builds that support camera tilt; on others they are accepted with NO visible effect — the result's tiltRenders says which this studio is, and a note appears when a tilt would be invisible.
3796
3909
  - easing: Timing function (linear, easeIn, easeOut, easeInOut, bounce, elastic)
3797
3910
 
3798
3911
  CURVED INTER-KEYFRAME PATHS (optional; default is linear):
@@ -3844,8 +3957,8 @@ EXAMPLE (curved arc between waypoints):
3844
3957
  items: { type: 'number' },
3845
3958
  description: 'Legacy view center [x, y] — honored only when focus is absent',
3846
3959
  },
3847
- pitch: { type: 'number', description: '3D tilt in degrees (0=flat, positive=forward tilt)' },
3848
- yaw: { type: 'number', description: '3D rotation in degrees' },
3960
+ pitch: { type: 'number', description: '3D tilt in degrees — rendered where the studio supports camera tilt (result.tiltRenders).' },
3961
+ yaw: { type: 'number', description: '3D rotation in degrees — rendered where the studio supports camera tilt (result.tiltRenders).' },
3849
3962
  easing: {
3850
3963
  type: 'string',
3851
3964
  enum: ['linear', 'easeIn', 'easeOut', 'easeInOut', 'bounce', 'elastic'],
@@ -3882,12 +3995,12 @@ EXAMPLE (curved arc between waypoints):
3882
3995
  },
3883
3996
  },
3884
3997
  },
3885
- duration: { type: 'number', description: 'Total animation duration in seconds' },
3998
+ duration: { type: 'number', description: 'Total animation duration in seconds. Optional: omitted, the camera runs to its last keyframe.' },
3886
3999
  loop: { type: 'boolean', description: 'Loop the animation' },
3887
4000
  delay: { type: 'number', description: 'Delay before animation starts' },
3888
4001
  fov: { type: 'number', description: 'Field of view in degrees for 3D perspective (default: 60)' },
3889
4002
  },
3890
- required: ['keyframes', 'duration'],
4003
+ required: ['keyframes'],
3891
4004
  },
3892
4005
  },
3893
4006
  {
@@ -4026,8 +4139,8 @@ SUPPORTED ANIMATIONS: bounce, breathe, fade, glow, jelly, path, pulse, rotate, s
4026
4139
  },
4027
4140
  itemType: {
4028
4141
  type: 'string',
4029
- enum: ['text', 'circle', 'star', 'rectangle', 'triangle', 'polygon', 'ellipse', 'path', 'line', 'arc'],
4030
- description: 'Type of item to create',
4142
+ enum: [...ItemTypeSchema.options],
4143
+ description: 'Type of item to create. Every type the engine has — this used to advertise ten while the tool hand-built four and turned the rest into a small circle.',
4031
4144
  },
4032
4145
  position: {
4033
4146
  type: 'object',
@@ -4315,7 +4428,7 @@ EXAMPLE:
4315
4428
  },
4316
4429
  description: `Shape a delay across many items. What makes a stagger read as designed rather than as a queue is where it STARTS and how it SPREADS — a grid lighting up outward from the centre, a row converging from both edges.
4317
4430
 
4318
- Delays are written to the channel the engine and the SMIL exporter already read, so a staggered scene scrubs, exports and restores. Nothing here is playback-only.
4431
+ Delays go where each kind of animation reads them: loop presets take animationDelay, and an item that already has a KEYFRAME track has that track's start offset (timeOffset) shifted by its delay — so apply the stagger after the keyframes, and re-applying replaces the shift rather than adding to it. The result's shiftedKeyframeTracks says how many tracks moved.
4319
4432
 
4320
4433
  ACTIONS:
4321
4434
  - apply: write the delays onto real items, in the order you list them (row-major for a grid).
@@ -4782,7 +4895,7 @@ Both actions return a WCAG contrast audit, so you can see failing pairs before s
4782
4895
  ACTIONS:
4783
4896
  - define: Turn items into a component master. Params: itemIds[], name
4784
4897
  - list: All defined components
4785
- - instantiate: Place an instance. Params: componentId, position, overrides
4898
+ - instantiate: Place an instance. Params: componentId, position, overrides → { instanceId, parts: [{key, type, content?}], orphaned }. overrides is { <componentKey>: { <prop>: value } }, e.g. { "ck_1": { "content": "50% OFF" }, "ck_2": { "fillColor": "#e11d48" } } — keys come from list (each component's parts) or from a previous instantiate.
4786
4899
  - set_override: Change ONE part of ONE instance without detaching it. Params: instanceId, componentKey, prop, value
4787
4900
  - sync: Push the master's current state to every instance (overrides are preserved). Params: componentId
4788
4901
  - update_from_instance: Promote an instance's edits back into the master. Params: instanceId
@@ -4810,7 +4923,7 @@ Overrides are the point: an instance can differ from its master and still receiv
4810
4923
  anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'object' }, { type: 'array' }],
4811
4924
  description: 'Override value',
4812
4925
  },
4813
- overrides: { type: 'object', description: 'Overrides applied at instantiate time' },
4926
+ overrides: { type: 'object', description: 'instantiate: { <componentKey>: { <prop>: value } } — keys are listed in parts by list and instantiate. A key the component does not have is reported in orphaned, not applied silently.' },
4814
4927
  },
4815
4928
  required: ['action'],
4816
4929
  },
@@ -4955,7 +5068,7 @@ node survives none of them.`,
4955
5068
  type: 'object',
4956
5069
  properties: {
4957
5070
  action: { type: 'string', enum: ['analyze', 'animate_to_beat'] },
4958
- source: { type: 'string', description: 'Asset id, data URL, or URL' },
5071
+ source: { type: 'string', description: 'Asset id, data URL, URL — or an uploaded clip\'s id / registryId from pinepaper_media upload_audio / upload_video.' },
4959
5072
  itemId: { type: 'string' },
4960
5073
  beats: { type: 'array', items: { type: 'number' }, description: 'Beat times (s) you already have' },
4961
5074
  grid: { type: 'boolean', description: 'Even pulse from the tempo instead of raw onsets' },
@@ -5045,26 +5158,28 @@ ACTIONS:
5045
5158
 
5046
5159
  Available filters (GPU raster set):
5047
5160
  - Color: grayscale, sepia, brightness, contrast, saturation, invert, posterize (levels), hsl (hue/saturation/lightness), colorTint (color, intensity, blendMode), colorMatrix (matrix), duotone (shadow, highlight, mix), paletteMap (swatches[], amount, preserveShading — recolor to a fixed palette)
5048
- - Stylize: vignette (intensity, radius), edgeDetect, halftoneDots (size, angle), halftoneCMYK (size), dither (levels), grain (amount, seed), scanlines (intensity, period), chromaticAberration (amount), blur
5161
+ - Stylize: vignette (intensity, radius), edgeDetect, halftoneDots (size, angle), halftoneCMYK (size), dither (levels), grain (amount, seed), scanlines (intensity, period), chromaticAberration (amount), blur (radius)
5049
5162
  - Light (additive): bloom (amount, threshold, radius), glow, halation (warm-fringed bloom; color), lightShafts (amount, threshold, decay, density, x, y, color — volumetric rays from a point)
5050
5163
  - Keying: lumaKey (threshold, softness, invert)
5051
5164
  - SECOND-INPUT (params.map = another item's id, resolved to its pixels): displace (amount, dispersion, map, mapChannel — the map's brightness pushes pixels), refract (displace with per-channel dispersion — glass), trackMatte (channel, invert, strength — prefer pinepaper_media apply_track_matte for the live version), datamosh (amount, block, map? — self-moshes without a map)
5052
5165
 
5053
5166
  RECIPE — grunge poster: chain [grain, scanlines, vignette]. Glass header: refract with map = a gradient item.
5167
+ HARD-EDGED LOOKS NEED SMOOTHING OFF: posterize, dither, halftone and pixel looks are exact at 1:1, but a raster that is then scaled, skewed or rotated is RESAMPLED with smoothing, which blends the flat levels back into a gradient (measured: posterize then skew → 256 tones again). Set smoothing 'off' on the image (pinepaper_import_image, or pinepaper_modify_item {smoothing: 'off'}) to keep the levels.
5168
+ CHAIN ORDER MATTERS: each filter runs on the previous one's output. duotone then halftoneDots comes out grey (the ink colour is lost); halftoneDots then duotone keeps it. If a chain loses its colour, move the colour-mapping filter (duotone, paletteMap, colorTint) to the end.
5054
5169
  (Scene-wide filters — sharpen/emboss/noise/vintage etc. — are a different surface: use pinepaper_add_filter.)`,
5055
5170
  inputSchema: {
5056
5171
  type: 'object',
5057
5172
  properties: {
5058
5173
  action: {
5059
5174
  type: 'string',
5060
- enum: ['apply', 'chain'],
5175
+ enum: ['apply', 'chain', 'analyze_palette', 'recolor_palette'],
5061
5176
  description: 'Filter action',
5062
5177
  },
5063
5178
  itemId: { type: 'string', description: 'Target raster item ID' },
5064
5179
  filterName: { type: 'string', description: 'Filter name (for apply)' },
5065
5180
  params: {
5066
5181
  type: 'object',
5067
- description: 'Filter parameters (e.g. {amount: 5} for blur)',
5182
+ description: "Filter parameters, named per filter in the list above. Blur takes {radius: 5} — a misspelled parameter is ignored rather than refused, so check the name. For blur only, 'amount' is accepted as an alias for radius because this example used to say so; every other filter that lists amount reads amount.",
5068
5183
  },
5069
5184
  filters: {
5070
5185
  type: 'array',
@@ -5076,8 +5191,18 @@ RECIPE — grunge poster: chain [grain, scanlines, vignette]. Glass header: refr
5076
5191
  },
5077
5192
  required: ['name'],
5078
5193
  },
5079
- description: 'Filter chain (for chain action)',
5194
+ description: 'Filter chain (for chain action). Order matters — see CHAIN ORDER above.',
5080
5195
  },
5196
+ maxSwatches: { type: 'integer', minimum: 1, maximum: 32, description: 'analyze_palette: swatch count cap (default 8).' },
5197
+ mapping: {
5198
+ oneOf: [
5199
+ { type: 'array', items: { type: 'object', properties: { from: { type: 'string' }, to: { type: 'string' } }, required: ['from', 'to'] } },
5200
+ { type: 'object', additionalProperties: { type: 'string' } },
5201
+ ],
5202
+ description: "recolor_palette: {from,to}[] or {'#old':'#new'} — colours swap, shading survives.",
5203
+ },
5204
+ amount: { type: 'number', minimum: 0, maximum: 1, description: 'recolor_palette: blend 0–1. (A filter\'s own amount goes in params.)' },
5205
+ preserveShading: { type: 'number', minimum: 0, maximum: 1, description: 'recolor_palette: how much of the original luminance detail survives (default 1).' },
5081
5206
  },
5082
5207
  required: ['action', 'itemId'],
5083
5208
  },
@@ -5091,20 +5216,28 @@ RECIPE — grunge poster: chain [grain, scanlines, vignette]. Glass header: refr
5091
5216
  idempotentHint: false,
5092
5217
  openWorldHint: false,
5093
5218
  },
5094
- description: `Freeform lasso selection for image items.
5219
+ description: `Cut a region out of an image along a polygon — the subject cutout behind 2.5D parallax, stickers and collage.
5095
5220
 
5096
5221
  ACTIONS:
5097
- - activate: Start lasso mode on an image item. Params: itemId
5098
- - apply: Confirm and apply the lasso selection`,
5222
+ - cut: { itemId, points: [[x, y], …] (canvas coordinates, at least 3 vertices, in order around the subject), keepOriginal? (default true) } → { cutoutId, originalId, originalKept }. The cutout is a NEW image item, placed exactly over the region it came from, transparent outside the polygon. keepOriginal:false consumes the source image (the studio's own lasso deletes it); the default keeps it, so you can layer the cutout over the photo.
5223
+ - activate / apply: the interactive, mouse-driven lasso. Refused over MCP — use cut.
5224
+
5225
+ Vertices closer than 12 px to the FIRST vertex are skipped (the lasso reads a click there as "close the shape"), so trace with points at least that far from where you started.`,
5099
5226
  inputSchema: {
5100
5227
  type: 'object',
5101
5228
  properties: {
5102
5229
  action: {
5103
5230
  type: 'string',
5104
- enum: ['activate', 'apply'],
5105
- description: 'Lasso action',
5231
+ enum: ['cut', 'activate', 'apply'],
5232
+ description: 'Lasso action — cut is the one that works over MCP',
5233
+ },
5234
+ itemId: { type: 'string', description: 'The image item to cut from' },
5235
+ points: {
5236
+ type: 'array',
5237
+ description: 'cut: polygon vertices in canvas coordinates, [x, y] or {x, y}, at least 3',
5238
+ items: { anyOf: [{ type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 }, { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'] }] },
5106
5239
  },
5107
- itemId: { type: 'string', description: 'Target image item ID (for activate)' },
5240
+ keepOriginal: { type: 'boolean', description: 'cut: keep the source image (default true). false = the source is consumed.' },
5108
5241
  },
5109
5242
  required: ['action'],
5110
5243
  },
@@ -5536,6 +5669,9 @@ Params: title (page title), download (bool)`,
5536
5669
  type: 'object',
5537
5670
  properties: {
5538
5671
  title: { type: 'string', description: 'HTML page title (default: PinePaper Widget)' },
5672
+ lang: { type: 'string', description: 'BCP 47 language tag for the page (default en), e.g. "ar", "hi", "zh-Hans".' },
5673
+ dir: { type: 'string', enum: ['ltr', 'rtl', 'auto'], description: 'Text direction for the page — "rtl" for Arabic / Hebrew.' },
5674
+ alt: { type: 'string', description: 'Text alternative for the animation: the canvas gets role="img" and this as its aria-label, and the page a meta description. Screen readers otherwise announce nothing.' },
5539
5675
  download: { type: 'boolean', description: 'Trigger browser download (default: false)' },
5540
5676
  },
5541
5677
  },
@@ -6441,6 +6577,7 @@ ACTIONS:
6441
6577
 
6442
6578
  ACTIONS:
6443
6579
  - enable — { rotation?: [lambda,phi,gamma], momentum?, showOcean? } switch to globe mode
6580
+ - disable — {} leave globe mode: stops spin, drag and rotation (reload the map for a flat projection)
6444
6581
  - rotate_to — { lon, lat, duration? } versor-slerp the globe to face a coordinate
6445
6582
  - spin — { speed?, axis?: "longitude"|"latitude", duration? } continuous auto-rotation
6446
6583
  - world_tour — { regions?: string[] | coords?: [lon,lat][], dwell?, travel?, tilt?, loop?, easing?, highlightColors?: string[], showLabels?, labelColor? } the headline: the globe swings to each stop, highlighting it (requires regions OR coords)
@@ -6450,7 +6587,7 @@ ACTIONS:
6450
6587
  inputSchema: {
6451
6588
  type: 'object',
6452
6589
  properties: {
6453
- action: { type: 'string', enum: ['enable', 'rotate_to', 'spin', 'world_tour', 'stop_tour', 'pin_item', 'tour_item'], description: 'Globe action' },
6590
+ action: { type: 'string', enum: ['enable', 'disable', 'rotate_to', 'spin', 'world_tour', 'stop_tour', 'pin_item', 'tour_item'], description: 'Globe action' },
6454
6591
  rotation: { type: 'array', items: { type: 'number' }, description: 'Initial rotation [lambda, phi, gamma] (enable)' },
6455
6592
  momentum: { type: 'boolean', description: 'Drag-spin inertia (enable)' },
6456
6593
  showOcean: { type: 'boolean', description: 'Render ocean sphere (enable)' },
@@ -6494,7 +6631,8 @@ ACTIONS:
6494
6631
  Most of this tool is glyph authoring — drawing a typeface. If you just want to know what font families are available to set on a text item, that is ONE action:
6495
6632
 
6496
6633
  - list_available — { category?, loadedOnly? } every family the studio can render, with whether its file is loaded yet
6497
- - check — { name, text? } is this family really there, and can it draw THIS string? Pass the text: a font with four glyphs is installed and will still render most characters in the fallback face, and that second answer is the one that decides the pixels. Do NOT rely on document.fonts.check() — it returns true for a family that does not exist.
6634
+ - load — { name } load a family NOW: the studio's catalogue first, then Google Fonts (e.g. Monoton). Returns available + via. Text tools also load on first use, so this is for checking up front.
6635
+ - check — { name, text? } is this family really there, and can it draw THIS string? "available: false" means NOT LOADED YET — the result says whether it is loadable; use load. Pass the text: a font with four glyphs is installed and will still render most characters in the fallback face, and that second answer is the one that decides the pixels. Do NOT rely on document.fonts.check() — it returns true for a family that does not exist.
6498
6636
  - fallbacks — {} which text items on the canvas are silently drawing in something other than the family they asked for
6499
6637
 
6500
6638
  ACTIONS (authoring):
@@ -6520,7 +6658,7 @@ ACTIONS (authoring):
6520
6658
  action: {
6521
6659
  type: 'string',
6522
6660
  enum: [
6523
- 'list_available', 'check', 'fallbacks',
6661
+ 'list_available', 'check', 'load', 'fallbacks',
6524
6662
  'show_studio', 'set_name', 'get_required_chars', 'get_status',
6525
6663
  'create_glyph', 'create_space', 'remove_glyph', 'set_metrics',
6526
6664
  'export', 'load_into_document', 'export_data', 'import_data',
@@ -6531,7 +6669,8 @@ ACTIONS (authoring):
6531
6669
  text: { type: 'string', description: 'check: the string you intend to draw. Without it you learn only whether the family is installed, not whether it has glyphs for your characters.' },
6532
6670
  category: { type: 'string', description: 'list_available: narrow to one category (e.g. display, serif, mono).' },
6533
6671
  loadedOnly: { type: 'boolean', description: 'list_available: only families whose font file has already loaded. Unloaded ones still render once the studio fetches them.' },
6534
- name: { type: 'string', description: 'Font name (set_name)' },
6672
+ name: { type: 'string', description: 'Font family (check, load) or the font being built (set_name). family / fontFamily are accepted for it.' },
6673
+ family: { type: 'string', description: 'Alias of name, for check / load.' },
6535
6674
  set: { type: 'string', description: 'Character set name (get_required_chars; default "minimum")' },
6536
6675
  character: { type: 'string', description: 'Single-character glyph identifier (create_glyph, remove_glyph)' },
6537
6676
  pathId: { type: 'string', description: 'Item id of the path source (create_glyph, remove_overlap, correct_direction, cleanup_path)' },
@@ -6794,7 +6933,7 @@ New — Geometric:
6794
6933
  - drawGeometricAbstract: Mixed translucent shapes (colors, shapeCount, blendMode, rotation)
6795
6934
 
6796
6935
  New — Particles:
6797
- - drawWindField: Directional wind particles with noise turbulence (particleCount, colors, direction, turbulence, trailLength, speed)
6936
+ - drawWindField: Directional wind particles with noise turbulence (particleCount, colors, windDirection (degrees), windSpeed, turbulence, trailLength, trailWidth, opacity, bgColor; direction / speed are accepted for windDirection / windSpeed)
6798
6937
 
6799
6938
  New — Textures:
6800
6939
  - drawNoiseTexture: Perlin/grain/stipple noise (noiseType: perlin|grain|stipple, colors, scale, density, animated)
@@ -6847,14 +6986,19 @@ COMMON PARAMS (all generators):
6847
6986
  },
6848
6987
  region: {
6849
6988
  type: 'object',
6850
- description: 'Optional sub-region {x, y, width, height} (canvas coords) — generator draws into a clipped, translated sub-group instead of full-canvas. Region runs imply preserve (won\'t wipe the canvas) and re-running the same generator replaces its prior region; different generators\' regions coexist. Invalid/zero-size regions fall back to full-canvas.',
6989
+ description: 'Optional sub-region {x, y, width, height} (canvas coords) — generator draws into a clipped, translated sub-group instead of full-canvas. Region runs imply preserve (won\'t wipe the canvas). An instance is the generator plus its box, or an explicit region.id: re-running with the same box (or id) replaces it, and the same generator in a different box adds another — so one generator several times, or several generators side by side, is regions. Older studios keep one region per generator (a second box replaces the first). Invalid/zero-size regions fall back to full-canvas. shape clips the region to a rounded rect, ellipse, circle, polygon or star, or to an existing path ({itemId}: x/y/width/height may then be omitted and default to its bounds). A studio without region shapes says so in the result (regionShapeApplied: false). On older studios, some generators also paint a page-wide background when confined to a region.',
6851
6990
  properties: {
6852
6991
  x: { type: 'number', description: 'Region top-left X in canvas coords' },
6853
6992
  y: { type: 'number', description: 'Region top-left Y in canvas coords' },
6854
6993
  width: { type: 'number', minimum: 1, description: 'Region width in pixels' },
6855
6994
  height: { type: 'number', minimum: 1, description: 'Region height in pixels' },
6995
+ shape: { anyOf: [{ type: 'string', enum: ['rect', 'roundedRect', 'ellipse', 'circle', 'polygon', 'star'] }, { type: 'object', properties: { itemId: { type: 'string' } }, required: ['itemId'] }], description: "Clip shape (default 'rect'), or {itemId} to clip to that path's geometry." },
6996
+ radius: { type: 'number', minimum: 0, description: 'roundedRect corner radius (default 20% of the shorter side).' },
6997
+ sides: { type: 'integer', minimum: 3, maximum: 64, description: 'polygon sides (default 6).' },
6998
+ points: { type: 'integer', minimum: 3, maximum: 64, description: 'star points (default 5).' },
6999
+ innerRatio: { type: 'number', minimum: 0.05, maximum: 0.95, description: 'star inner radius / outer (default 0.5).' },
7000
+ id: { type: 'string', minLength: 1, maxLength: 64, description: 'Names this region instance, so re-running under the same id replaces it even if its box moved.' },
6856
7001
  },
6857
- required: ['x', 'y', 'width', 'height'],
6858
7002
  },
6859
7003
  },
6860
7004
  required: ['generatorName'],
@@ -6986,13 +7130,14 @@ AVAILABLE FILTERS:
6986
7130
  - blur: Gaussian blur effect (params: radius 0-20)
6987
7131
  - brightness: Adjust brightness (params: value -100 to 100)
6988
7132
  - contrast: Adjust contrast (params: value -100 to 100)
6989
- - saturate: Adjust color saturation (params: value -100 to 100)
7133
+ - saturation: Adjust color saturation (params: value -100 to 100). 'saturate' is accepted as the same filter.
6990
7134
  - invert: Invert colors (params: intensity 0-1)
6991
7135
  - noise: Add film grain (params: intensity 0-100, monochrome: true/false)
6992
7136
  - vignette: Darken edges (params: intensity 0-1, radius 0-1)
6993
7137
  - vintage: Preset combining sepia, vignette, noise
6994
7138
  - colorOverlay: Add color tint (params: color, intensity, blendMode)
6995
7139
  - sharpen: Increase sharpness (params: intensity 0-100)
7140
+ - emboss: 3D emboss (params: intensity 0-1)
6996
7141
  - posterize: Reduce color levels (params: levels 2-32)
6997
7142
 
6998
7143
  EXAMPLES:
@@ -7007,7 +7152,7 @@ Filters can be stacked - call multiple times to combine effects.`,
7007
7152
  properties: {
7008
7153
  filterType: {
7009
7154
  type: 'string',
7010
- enum: ['grayscale', 'sepia', 'blur', 'brightness', 'contrast', 'saturate', 'invert', 'noise', 'vignette', 'vintage', 'colorOverlay', 'sharpen', 'posterize'],
7155
+ enum: ['grayscale', 'sepia', 'blur', 'brightness', 'contrast', 'saturation', 'invert', 'noise', 'vignette', 'vintage', 'colorOverlay', 'sharpen', 'emboss', 'posterize', 'saturate'],
7011
7156
  description: 'Type of filter to apply',
7012
7157
  },
7013
7158
  params: {
@@ -7439,7 +7584,7 @@ WORKFLOW: start_job → batch_execute (everything in ONE call) → end_job (scre
7439
7584
 
7440
7585
  RETURNS: canvasSize {width, height} — use this to position items within bounds. Also returns ontology: a pp: triples + item summary snapshot of the (typically empty) canvas. Reuse this baseline across the same agent turn instead of calling pinepaper_get_canvas_ontology unless you've mutated the canvas. When clearCanvas is false, the ontology captures whatever's already on the canvas.
7441
7586
 
7442
- CANVAS PRESETS: instagram (1080x1080), instagram-story (1080x1920), tiktok (1080x1920), youtube (1920x1080), youtube-thumbnail (1280x720), twitter (1200x675), linkedin (1200x627), web, print-a4, print-letter`,
7587
+ CANVAS PRESETS: instagram (1080x1080), instagram-story (1080x1920), tiktok (1080x1920), youtube (1920x1080), youtube-thumbnail (1280x720), twitter (1200x675), linkedin (1200x627), web, print-a4, print-letter, print-a4-landscape, print-letter-landscape (print presets carry 300 dpi, so a PDF comes out at paper size)`,
7443
7588
  inputSchema: {
7444
7589
  type: 'object',
7445
7590
  properties: {
@@ -7453,12 +7598,12 @@ CANVAS PRESETS: instagram (1080x1080), instagram-story (1080x1920), tiktok (1080
7453
7598
  },
7454
7599
  screenshotPolicy: {
7455
7600
  type: 'string',
7456
- enum: ['on_complete', 'on_error', 'never', 'on_request'],
7457
- description: 'When to take screenshots (default: on_complete)',
7601
+ enum: ['on_complete', 'on_error', 'none', 'on_request', 'never'],
7602
+ description: "When to take screenshots (default: on_complete). 'never' is accepted as 'none'.",
7458
7603
  },
7459
7604
  canvasPreset: {
7460
7605
  type: 'string',
7461
- enum: ['instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter'],
7606
+ enum: ['instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter', 'print-a4-landscape', 'print-letter-landscape'],
7462
7607
  description: 'Set canvas size to platform preset',
7463
7608
  },
7464
7609
  clearCanvas: {
@@ -7565,7 +7710,7 @@ EXAMPLES:
7565
7710
  properties: {
7566
7711
  canvasPreset: {
7567
7712
  type: 'string',
7568
- enum: ['instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter'],
7713
+ enum: ['instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter', 'print-a4-landscape', 'print-letter-landscape'],
7569
7714
  description: 'Set canvas size to platform preset after reset',
7570
7715
  },
7571
7716
  backgroundColor: {
@@ -7594,7 +7739,7 @@ NOTE: For diagrams (flowcharts, UML), use pinepaper_create_diagram_shape + pinep
7594
7739
 
7595
7740
  ⚠️ EVERY operation executes LIVE on the canvas. Calling this twice DOUBLES all items. Call ONCE per pipeline.
7596
7741
 
7597
- ⚠️ SIZING: All positions/sizes MUST fit within the canvas. start_job returns canvasSize. Keep items within 5%-95% of canvas width/height. Max canvas: 4096x4096.
7742
+ ⚠️ SIZING: All positions/sizes MUST fit within the canvas. start_job returns canvasSize. Keep items within 5%-95% of canvas width/height. Max canvas: 8192x8192.
7598
7743
 
7599
7744
  ONTOLOGY PREFLIGHT: Before code generation, this tool validates itemType, relationType, effectType, and generatorName against the canonical PinePaper vocabulary. A typo (e.g. "circel") returns a structured VALIDATION_ERROR with the operation index, the offending field, the canonical valid list, and a suggested fix — fix and retry rather than guessing. Pass skipValidation: true to bypass when intentionally using experimental vocabulary.
7600
7745
 
@@ -7630,8 +7775,8 @@ ANIMATION:
7630
7775
  animate — Loop animation: {itemId, animationType, animationOptions}
7631
7776
  animationType: ${SIMPLE_ANIMATION_TYPES_LIST}
7632
7777
  keyframe_animate — Timed animation: {itemId, keyframes: [{time, properties, easing}], duration, loop}
7633
- Animatable properties: opacity, scale, scaleX, scaleY, x, y, rotation, fillColor, strokeColor, fontSize
7634
- Easing: easeInOut, easeIn, easeOut, easeInCubic, easeOutCubic, easeOutBounce, linear
7778
+ Animatable properties: opacity, scale, scaleX, scaleY, x, y, rotation, fillColor, strokeColor, strokeWidth, width, height, radius, fontSize, trimStart/trimEnd/trimOffset (path draw-on)
7779
+ Easing (on the keyframe being moved TO): ${KEYFRAME_EASINGS_LIST}
7635
7780
  relation — Behavioral link: {sourceId, targetId, relationType, relationOptions}
7636
7781
  relationType: orbits, follows, attached_to, points_at, mirrors, parallax, wave_through, morphs_to, group_morphs_to, moves_along_path (+ 9 more in schema)
7637
7782
 
@@ -7681,8 +7826,8 @@ EXAMPLE — Animated sky scene with timed reveals:
7681
7826
  description: 'Operation type',
7682
7827
  },
7683
7828
  // Canvas setup
7684
- width: { type: 'number', description: 'Canvas width for set_canvas_size (100-4096)' },
7685
- height: { type: 'number', description: 'Canvas height for set_canvas_size (100-4096)' },
7829
+ width: { type: 'number', description: 'Canvas width for set_canvas_size (100-8192)' },
7830
+ height: { type: 'number', description: 'Canvas height for set_canvas_size (100-8192)' },
7686
7831
  preset: { type: 'string', description: 'Canvas preset for set_canvas_size' },
7687
7832
  backgroundColor: { type: 'string', description: 'For set_background: hex color' },
7688
7833
  generatorName: {
@@ -7834,7 +7979,10 @@ Held exports last until released, or until a LATER export needs the space — an
7834
7979
 
7835
7980
  WHAT quality ACTUALLY SETS, because it bundles three things and only one of them is "quality": draft = 15fps / 72dpi / compression 0.6, standard = 30fps / 150dpi / 0.85, high = 60fps / 300dpi / 0.95. So asking for high DOUBLES the frame count and the render time against standard. Override either half directly - fps for frame rate, scale for resolution - and leave quality to mean compression.
7836
7981
 
7837
- DOES THIS SURVIVE EXPORT? It is a PATH fact, not a per-feature one, so the rule is short and does not go stale as capabilities land: IF IT TICKS INSIDE THE ENGINE'S UPDATE LOOP, IT EXPORTS. Loop animations, relations, keyframes, generators and camera moves all do - measured frame-by-frame, not assumed. What does NOT survive is anything driven by the wall clock outside that loop, or anything on the realtime recorder path.
7982
+ DOES THIS SURVIVE EXPORT? It is a PATH fact, not a per-feature one, so the rule is short and does not go stale as capabilities land: IF IT TICKS INSIDE THE ENGINE'S UPDATE LOOP, IT EXPORTS. Loop presets (pinepaper_animate), relations, keyframes, generators and camera moves all do - measured frame-by-frame, not assumed. What does NOT survive is anything driven by the wall clock outside that loop, or anything on the realtime recorder path.
7983
+ ONE MEASURED EXCEPTION TO CHECK: keyframe_animate's loop:true has been measured playing ONCE in an export and then holding the last keyframe. Before exporting past one cycle, sample a frame in the second cycle; if it holds, write the keyframes out for each cycle, or use a loop preset.
7984
+
7985
+ SEAMLESS LOOPS: there is no loop switch on export. Key the animation to the SAME state at t = 0 and at t = duration, and export exactly that duration — the export renders t = 0 up to one frame before duration, so no frame is doubled at the wrap. A GIF's play count is the loop parameter.
7838
7986
 
7839
7987
  If exported frames look frozen, check the SAMPLING before believing it: a loop animation at animationSpeed 1 has a one-second period, so two frames a whole second apart are identical by design. Compare frames that are not a whole number of periods apart.
7840
7988
 
@@ -7848,6 +7996,8 @@ PLATFORMS & OPTIMAL FORMATS:
7848
7996
  | twitter | 1200x675 | PNG | GIF |
7849
7997
  | web | flexible | SVG | SVG |
7850
7998
 
7999
+ A preset's dimensions assume the canvas has the same aspect. When it does not, the canvas aspect is KEPT (never stretched) and its short edge takes the preset's short edge — a 4:5 canvas on instagram exports 1080x1350 — and the result carries platformFit saying so.
8000
+
7851
8001
  FORMATS: svg, png, gif, mp4, webm, pdf.
7852
8002
  QUALITY: draft, standard, high.
7853
8003
  FRAMING: canvas (default, full canvas) | camera (camera_animates first-keyframe viewport — video formats only, requires a keyframe-mode walkthrough on the canvas; camera animation still drives motion within the fixed output frame).
@@ -7862,19 +8012,21 @@ EXAMPLES:
7862
8012
  - {platform: "youtube", format: "mp4", framing: "camera"} — render only what the camera frames during the walkthrough
7863
8013
  - {platform: "youtube", format: "mp4", duration: 30, estimateOnly: true} — how big would that be, and what would it lose? (renders nothing)
7864
8014
 
7865
- Every export answers the second question too: where this scene loses something to the format you chose, the result carries "fidelity" naming it. Its ABSENCE means this scene has nothing that format would drop — not that the format is lossless.`,
8015
+ Every export answers the second question too: the result carries "fidelity", which says whether the check could run at all (available), what this scene loses to the format you chose (warnings), and what that verdict was derived from (checked). An empty warnings list with a note means this SCENE loses nothing to this format — not that the format is lossless.
8016
+
8017
+ VERIFY MOTION BEFORE YOU RENDER. An export takes seconds to minutes and shows you the result only at the end, by which time a frozen animation has cost you the whole render. pinepaper_capture_frames samples the timeline and reports allIdentical plus per-frame hashes, so a scene that is not moving is one cheap call away — it is the fastest way to catch a frozen chart, an animation whose tail never fires, or a curve that was never drawn. Run it before a long export, not after.`,
7866
8018
  inputSchema: {
7867
8019
  type: 'object',
7868
8020
  properties: {
7869
8021
  platform: {
7870
8022
  type: 'string',
7871
- enum: ['auto', 'instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter'],
8023
+ enum: ['auto', 'instagram', 'instagram-story', 'tiktok', 'youtube', 'youtube-thumbnail', 'twitter', 'linkedin', 'web', 'print-a4', 'print-letter', 'print-a4-landscape', 'print-letter-landscape'],
7872
8024
  description: "Target platform, which sets the output DIMENSIONS. 'auto' (the default) renders the canvas at its own size — use it when the canvas is already the size you want, otherwise a preset REPLACES your dimensions. Every other value forces that platform's frame regardless of the canvas.",
7873
8025
  },
7874
8026
  format: {
7875
8027
  type: 'string',
7876
- enum: ['svg', 'png', 'gif', 'mp4', 'webm', 'pdf', 'wav'],
7877
- description: "Override format (auto-detected if not specified). 'wav' exports the SOUNDTRACK ON ITS OWN, with no frames rendered — no platform preset resolves to it, so it must be asked for by name, and platform dimensions, framing and quality do not apply.",
8028
+ enum: ['svg', 'png', 'gif', 'apng', 'mp4', 'webm', 'pdf', 'jpg', 'webp', 'srt', 'vtt', 'wav', 'scc', 'html5-ad', 'playable'],
8029
+ description: "Override format (auto-detected if not specified). 'scc' is broadcast CEA-608 captions (CC1, pop-on, 29.97 drop-frame; two rows of 32 characters, Latin set) from the same timed text as srt / vtt — the result names captions cut, characters dropped and captions that had to go on late. 'html5-ad' is a display-network HTML5 ad (Google Ads, DV360 / CM360, most DSPs): a zip with index.html carrying the ad.size meta and a clickTag click target (the whole ad, or ad.ctaItemId's box), plus a backup PNG saved beside it; the result checks the zip against ad.maxBytes (default 150000) and lists any external requests. 'playable' is an MRAID playable: one HTML file loading mraid.js, whose CTA (ad.ctaItemId, required) opens ad.clickUrl (required) through mraid.open. Both are built from the standalone widget page at the canvas size. 'apng' is animation with FULL alpha (stepped PNG frames, saved as .png; transparent unless transparent: false) — the answer for an animated overlay or sticker, since webm here cannot carry alpha and gif's transparency is 1-bit. 'jpg' / 'webp' are stills for a byte budget (ad specs): the png render re-encoded at the quality tier's compression — draft 0.6, standard 0.85, high 0.95 — so lower quality means a smaller file; 'srt' / 'vtt' write a caption file from every text item that has a lifetime (bornAt / ttl) — one cue each, in start order; duration closes a caption with no ttl. jpg has no transparency: it is flattened onto the scene's background colour, or white when none is set. 'wav' exports the SOUNDTRACK ON ITS OWN, with no frames rendered — no platform preset resolves to it, so it must be asked for by name, and platform dimensions, framing and quality do not apply.",
7878
8030
  },
7879
8031
  sampleRate: {
7880
8032
  type: 'number',
@@ -7885,22 +8037,63 @@ Every export answers the second question too: where this scene loses something t
7885
8037
  enum: [16, 32],
7886
8038
  description: 'wav only: 16 (default) or 32-bit float. Rejected for any other format.',
7887
8039
  },
8040
+ ad: {
8041
+ type: 'object',
8042
+ description: 'html5-ad / playable options.',
8043
+ properties: {
8044
+ clickUrl: { type: 'string', description: 'html5-ad: the clickTag value for testing (networks replace it at serve time). playable: the store / landing URL the CTA opens (required).' },
8045
+ ctaItemId: { type: 'string', description: 'The item whose bounds are the click target. Optional for html5-ad (default: whole ad); required for playable. Static: where the item is at export time.' },
8046
+ maxBytes: { type: 'integer', minimum: 1, description: 'Upload size budget (zip for html5-ad, HTML for playable). html5-ad default 150000 (Google Ads display); set your network figure.' },
8047
+ },
8048
+ },
8049
+ transparent: { type: 'boolean', description: 'apng: keep the alpha channel (the default; false fills the background colour). webm: true writes a VP9 alpha channel where the studio supports it; result.video.alpha confirms it, and fidelity says when it did not. Not for mp4 / gif.' },
8050
+ alphaQuantizer: { type: 'integer', minimum: 0, maximum: 63, description: 'webm with transparent: alpha-stream quality, 0 = lossless … 63.' },
8051
+ loop: { anyOf: [{ type: 'boolean' }, { type: 'integer', minimum: 0, maximum: 1000 }], description: 'gif / apng: true = loop forever, false / 0 / 1 = play once, n = play n times (email clients often want a few plays, not forever). Refused on video formats — see SEAMLESS LOOPS.' },
8052
+ broadcast: { type: 'boolean', description: 'mp4 only: broadcast-safe — BT.709, limited range (16-235), tagged bt709, constant bitrate with an 8 Mbps floor at 720p+ (4 below). result.video reports what the encoder did.' },
8053
+ broadcastHeadroom: { type: 'integer', minimum: 0, maximum: 40, description: 'mp4 with broadcast only, default 12: luma codes kept clear at both ends, so encoder ringing stays legal (12 measured clean on hard edges; 4 = ~100x fewer out-of-range samples, 12 = ~1000x; never zero). Fidelity raises luma_out_of_range with the legaliser command when any remain.' },
8054
+ bitrate: { type: 'integer', minimum: 100000, maximum: 200000000, description: 'mp4 / webm: target bits/s, replacing the quality-derived one. The browser encoder treats it as a CEILING; result.video.achievedBitrate is what it produced.' },
8055
+ minBitrate: { type: 'integer', minimum: 100000, maximum: 200000000, description: 'mp4 / webm: a floor; a miss is a fidelity warning naming the re-encode a delivery spec needs.' },
8056
+ bitrateMode: { type: 'string', enum: ['constant', 'variable'], description: "mp4 / webm: 'constant' (the default with broadcast) or 'variable'." },
8057
+ maxBytes: { type: 'integer', minimum: 1, description: 'gif only: size budget in bytes (email: 1000000). Over it, the GIF is re-encoded at a smaller frame size, at most twice; result.budget reports each attempt.' },
8058
+ time: { type: 'number', minimum: 0, description: 'Stills only (png / jpg / webp / svg / pdf): render at this time in seconds; the playhead is put back afterwards. Without it a still is whatever frame the playhead is on, so repeated runs can differ.' },
8059
+ pdf: {
8060
+ type: 'object',
8061
+ description: "pdf only: print options. paperFormat 'a4' | 'a3' | 'letter' | … | 'custom' (canvas size, default); orientation; bleed in mm (> 0 includes the bleed area); trimMarks adds crop marks; dpi 72–600. Bleed and trim marks take effect with paperFormat 'custom' (the page is the canvas plus bleed) — on a named size the page stays that size and they are dropped, which the result's warnings say. The canvas's physical size comes from its preset's DPI: start the job with canvasPreset 'print-a4' / 'print-letter' / 'print-a4-landscape' / 'print-letter-landscape' (300 dpi) for print; a hand-sized canvas is read at 96 dpi.",
8062
+ properties: {
8063
+ paperFormat: { type: 'string' },
8064
+ orientation: { type: 'string', enum: ['portrait', 'landscape'] },
8065
+ bleed: { type: 'number', minimum: 0, maximum: 20 },
8066
+ trimMarks: { type: 'boolean' },
8067
+ searchableText: { type: 'boolean', description: 'Default true where the studio supports it: an invisible text layer over the artwork, so the PDF is selectable, searchable and screen-readable. Lines no font in the PDF can carry (older studios have only a built-in Latin font) are drawn but left out, and fidelity says how many and which fonts could not be embedded. Multi-page (pages) exports get it per page on studios that support it. false omits it. result.pdf reports linesWritten / linesSkipped.' },
8068
+ dpi: { type: 'integer', minimum: 72, maximum: 600 },
8069
+ pages: { anyOf: [{ type: 'string', enum: ['scenes'] }, { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: { sceneId: { type: 'string' }, width: { type: 'number' }, height: { type: 'number' } }, required: ['sceneId', 'width', 'height'] }] }, minItems: 1 }], description: "Multi-page PDF: 'scenes' = one page per saved scene (the scenes tool's save action) in timeline order, or an ordered list of scene ids. Saved scenes do NOT record their canvas size, so every page uses the current canvas size unless you give it: {sceneId, width, height} (pixels) per page. Pages are embedded as JPEG at the quality tier's compression." },
8070
+ },
8071
+ },
8072
+ region: {
8073
+ type: 'object',
8074
+ description: 'png / jpg / webp only: export just this part of the canvas (canvas coordinates, x/y = TOP-LEFT) — carousel slices, crops. Output is the region\'s own size unless outputWidth/outputHeight say otherwise; a different aspect is covered, not stretched. SMALL ASSETS (emotes, badges, favicons): design at 2-4x on a canvas of at least 100 px, then export the whole board as a region with the small outputWidth/outputHeight — e.g. a 112x112 canvas, region {x:0, y:0, width:112, height:112, outputWidth:28, outputHeight:28}.',
8075
+ properties: {
8076
+ x: { type: 'number' }, y: { type: 'number' },
8077
+ width: { type: 'number', exclusiveMinimum: 0 }, height: { type: 'number', exclusiveMinimum: 0 },
8078
+ outputWidth: { type: 'integer', minimum: 1, maximum: 8192 }, outputHeight: { type: 'integer', minimum: 1, maximum: 8192 },
8079
+ excludeForeign: { type: 'boolean', description: 'Default true: an item reaching into the region but centred outside it (a neighbouring card overflow) is left out of this image; result.excludedItems names them. false renders everything that touches the region.' },
8080
+ },
8081
+ required: ['x', 'y', 'width', 'height'],
8082
+ },
7888
8083
  quality: {
7889
8084
  type: 'string',
7890
8085
  enum: ['draft', 'standard', 'high'],
7891
8086
  description: 'Compression level (default: standard). It ALSO carries a default frame rate — draft 15, standard 30, high 60 — so "high" doubles the frame count and the render time against "standard" unless you set fps yourself. Set fps and scale to control those halves directly and leave quality meaning compression.',
7892
8087
  },
7893
8088
  fps: {
7894
- type: 'integer',
7895
- minimum: 1,
7896
- maximum: 120,
7897
- description: 'Video only: frames per second, overriding whatever `quality` implies. File size is bitrate x duration and does not move with fps, but the picture gets fewer bits per frame at a higher one.',
8089
+ anyOf: [{ type: 'number', minimum: 1, maximum: 120 }, { type: 'string', pattern: '^[0-9]+/[0-9]+$' }],
8090
+ description: 'Video only: frames per second, overriding whatever `quality` implies. Integers, NTSC rates (23.976, 29.97, 59.94 — snapped to the exact 24000/1001, 30000/1001, 60000/1001, which CTV / OTT specs require) or a rational string "30000/1001". At an NTSC rate a duration of N seconds is round(N x fps) frames, so 6 s at 29.97 is 180 frames = 6.006 s — the result says when that overruns the duration you asked for. File size is bitrate x duration and does not move with fps.',
7898
8091
  },
7899
8092
  scale: {
7900
8093
  type: 'number',
7901
8094
  minimum: 0.1,
7902
8095
  maximum: 1,
7903
- description: 'Video only: render at this fraction of the platform preset dimensions (0.1-1). The engine derives its encode target from RESOLUTION, so this is the size control — there is no bitrate to set, and halving the frame roughly quarters the pixels and the file. It is also the preview knob: scale 0.5 with quality "draft" is the fast look-check before committing to a full render. Rounded to even dimensions, which H.264 requires.',
8096
+ description: 'Video only: render at this fraction of the platform preset dimensions (0.1-1). The engine derives its encode target from RESOLUTION unless bitrate is given, so this is the size control; halving the frame roughly quarters the pixels and the file. It is also the preview knob: scale 0.5 with quality "draft" is the fast look-check before committing to a full render. Rounded to even dimensions, which H.264 requires.',
7904
8097
  },
7905
8098
  framing: {
7906
8099
  type: 'string',
@@ -8472,6 +8665,7 @@ DATA FORMAT: Array of objects, e.g. [{category: "A", value: 10}, {category: "B",
8472
8665
 
8473
8666
  OPTIONS (vary by chart type):
8474
8667
  - Common: title, width, height, x, y, colors, animation, axisColor, labelColor, labelFontSize
8668
+ x / y are the chart's CENTRE (measured: x 1000, y 300 at 800x600 reads back as bounds [600, -25, 800, 625]) — pass the centre of the rect you want it in, not its top-left.
8475
8669
  - Bar: barWidth, barGap, xLabel, yLabel
8476
8670
  orient: 'horizontal' flips bars to grow rightward from a left Y-axis — best for long category names
8477
8671
  labelArrangement: 'auto'|'horizontal'|'slanted'|'vertical'|'truncate'|'wrap'