@pinepaper.studio/mcp-server 1.6.32 → 1.6.33

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 (46) hide show
  1. package/README.md +53 -0
  2. package/dist/agent/types.d.ts +3 -3
  3. package/dist/build-info.json +2 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/ontology/upstream-vocabulary.d.ts +1 -1
  8. package/dist/ontology/upstream-vocabulary.js +1 -1
  9. package/dist/tools/definitions.d.ts.map +1 -1
  10. package/dist/tools/definitions.js +51 -38
  11. package/dist/tools/definitions.js.map +1 -1
  12. package/dist/tools/engine-lists.d.ts +5 -3
  13. package/dist/tools/engine-lists.d.ts.map +1 -1
  14. package/dist/tools/engine-lists.js +11 -3
  15. package/dist/tools/engine-lists.js.map +1 -1
  16. package/dist/tools/engine-surface.d.ts +1 -1
  17. package/dist/tools/engine-surface.d.ts.map +1 -1
  18. package/dist/tools/engine-surface.js +10 -4
  19. package/dist/tools/engine-surface.js.map +1 -1
  20. package/dist/tools/handlers.d.ts.map +1 -1
  21. package/dist/tools/handlers.js +59 -11
  22. package/dist/tools/handlers.js.map +1 -1
  23. package/dist/tools/lane.js +2 -2
  24. package/dist/tools/lane.js.map +1 -1
  25. package/dist/tools/reply-fields.d.ts +15 -0
  26. package/dist/tools/reply-fields.d.ts.map +1 -0
  27. package/dist/tools/reply-fields.js +117 -0
  28. package/dist/tools/reply-fields.js.map +1 -0
  29. package/dist/tools/scene-cache-export.d.ts.map +1 -1
  30. package/dist/tools/scene-cache-export.js +2 -1
  31. package/dist/tools/scene-cache-export.js.map +1 -1
  32. package/dist/tools/shape-params.d.ts +2 -2
  33. package/dist/tools/shape-params.d.ts.map +1 -1
  34. package/dist/tools/shape-params.js +4 -2
  35. package/dist/tools/shape-params.js.map +1 -1
  36. package/dist/tools/stick-vocabulary.d.ts +1 -1
  37. package/dist/tools/stick-vocabulary.js +1 -1
  38. package/dist/types/code-generator.d.ts.map +1 -1
  39. package/dist/types/code-generator.js +113 -34
  40. package/dist/types/code-generator.js.map +1 -1
  41. package/dist/types/schemas.d.ts +31 -6
  42. package/dist/types/schemas.d.ts.map +1 -1
  43. package/dist/types/schemas.js +11 -3
  44. package/dist/types/schemas.js.map +1 -1
  45. package/manifest.json +1 -1
  46. package/package.json +1 -1
@@ -437,11 +437,11 @@ IMPORTANT NOTES:
437
437
  "the engine's own. Three further parameters are each\nread by six of these eight shapes, and THE THREE SIXES ARE NOT THE SAME SIX —\na shape missing from a list ignores that parameter silently, so check before\nyou pass it:\n cornerRadius — speech-bubble, speech-bubble-pointed, comment-box,\n callout-box, double-bubble, quote-bubble\n tailDirection (bottom-left | bottom-right | bottom | top-left | top-right |\n top | left | right) — speech-bubble, speech-bubble-square,\n speech-bubble-pointed, thought-bubble, comment-box, callout-box\n tailSize (0-1, default 0.35) — speech-bubble, speech-bubble-square,\n speech-bubble-pointed, comment-box, callout-box, double-bubble\n- speech" +
438
438
  "-bubble: Classic round speech bubble with triangular tail.\n- speech-bubble-square: Rectangular speech bubble with sharp corners and\n pointed tail.\n- speech-bubble-pointed: Tooltip-style bubble with arrow pointer (tail defaults\n to \"bottom\").\n- thought-bubble: Cloud-like thought bubble with trailing dots.\n- comment-box: Modern chat message bubble with high corner radius.\n- callout-box: Annotation callout with narrow arrow (tail defaults to \"left\").\n- double-bubble: Two overlapping bubbles for conversation (properties:\n secondaryColor).\n- quote-bubble: Stylized bubble with quotation marks.\n\nPATH FOR CUSTOM SHAPES:\nUse itemType: \"path\" with either:\n- segments: Array of " +
439
439
  "[x,y] points for polygons, e.g. [[100,100], [200,50], [300,100]]\n- pathData: SVG path string for curves, e.g. \"M 100 100 C 150 50, 250 50, 300 100 Z\"\n\nADVANCED STYLING (NEW):\nAll shapes support these additional properties:\n- No fill (stroke-only outlines, line art): fillColor: null, 'transparent' or 'none', with a strokeColor and strokeWidth.\n- fillOpacity (0..1): the solid fill's own alpha, apart from opacity (which fades the stroke too) — on text, fillOpacity: 0.3 with a strokeColor is a see-through letter with a solid outline. Not applied to a gradient fill. The result's fillOpacity says whether the studio applied it (an older studio ignores the key and says so).\n- Gradients: c" +
440
- "olor can be an object: {type: \"linear\"|\"radial\", stops: [{color: \"#fff\", offset: 0}, {color: \"#000\", offset: 1}], origin: [x,y], destination: [x,y]}. Without origin / destination a radial gradient is centred on the item's own bounds and reaches its right edge, wherever anchor put it.\n- Shadows: shadowColor, shadowBlur (pixels), shadowOffset: [x, y]\n- Blend modes: blendMode: \"multiply\"|\"screen\"|\"overlay\"|\"darken\"|\"lighten\"|etc.\n- Opacity: opacity (0-1)\n\nEXAMPLES:\n- \"Create red text saying HELLO\" → itemType: \"text\", properties: {content: \"HELLO\", color: \"#ef4444\", fontSize: 48}\n- \"Add a blue circle\" → itemType: \"circle\", properties: {radius: 50, color: \"" +
441
- "#3b82f6\"}\n- \"Create a 5-pointed gold star\" → itemType: \"star\", properties: {radius1: 60, radius2: 30, points: 5, color: \"#fbbf24\"}\n- \"Draw a heart\" → itemType: \"path\", properties: {pathData: \"M 300 350 C 300 300, 250 250, 200 250 C 150 250, 100 300, 100 350 C 100 450, 300 550, 300 550 C 300 550, 500 450, 500 350 C 500 300, 450 250, 400 250 C 350 250, 300 300, 300 350 Z\", fillColor: \"#ef4444\"}\n- \"Circle with radial gradient\" → itemType: \"circle\", properties: {radius: 50, color: {type: \"radial\", stops: [{color: \"#ff6b6b\", offset: 0}, {color: \"#c0392b\", offset: 1}]}}\n- \"Rectangle with shadow\" → itemType: \"rectangle\", properties: {width: 100, height: 50, color:" +
442
- " \"#3b82f6\", shadowColor: \"rgba(0,0,0,0.5)\", shadowBlur: 10, shadowOffset: [5, 5]}\n\nITEM PROVENANCE:\nItems on the canvas can originate from multiple sources — all become first-class items with itemIds:\n- pinepaper_create_item: Direct shape creation (this tool)\n- pinepaper_import_svg: SVG file import → becomes a full item\n- pinepaper_import_image: Raster image import → becomes a full item\n- pinepaper_import_asset: Pre-made asset import → becomes a full item\nAll imported items participate in the relation and animation systems just like natively created items.\n\nRENDER-TIME SURFACES — 'shader' and 'field':\nThese are not shapes. A shape is built as an item and drawn from its geome" +
443
- "try; a\nsurface has no geometry — it is evaluated, per pixel or per sample, as each frame\nis drawn. Use them for things a vector cannot be: an open sea, a drifting field\nof marks, a lit material.\n- shader: pick one of water | liquid_metal | heatmap | gem_smoke, give it width\n and height, and set behind:true to place it UNDER the vector art — which is\n what scenery wants, a sea the boats float ON rather than a sheen over them.\n- field: xExpr/yExpr/count draw many marks from one expression, re-evaluated\n every frame against t. rExpr and aExpr vary size and alpha per mark.\nBoth draw fully in a cloud render; on a local canvas they stand in as a flat\nplate of their own colour so the" +
444
- " layout still reads.\n\nLIFETIMES — bornAt and ttl, on any item:\nThe only way to build a piece that CUTS between shots. Without them every item\nis on screen for the whole render, which is what makes a long piece read as one\ncrowded frame instead of a sequence.\n\nWORKFLOW TIP:\nAfter creating items, use pinepaper_add_relation to animate them (e.g., orbits, follows, attached_to).\nFor glossy 3D spheres, use pinepaper_create_glossy_sphere instead. For diagonal stripes, use pinepaper_create_diagonal_stripes."
440
+ "olor can be an object: {type: \"linear\"|\"radial\", stops: [{color: \"#fff\", offset: 0}, {color: \"#000\", offset: 1}], origin: [x,y], destination: [x,y]}. Without origin / destination a radial gradient is centred on the item's own bounds and reaches its right edge, wherever anchor put it; a linear one is a short fixed ramp around the item's centre, not fitted to its bounds, so pass origin and destination (canvas px) for a ramp across the item.\n- Shadows: shadowColor, shadowBlur (pixels), shadowOffset: [x, y]\n- Blend modes: blendMode: \"multiply\"|\"screen\"|\"overlay\"|\"darken\"|\"lighten\"|etc.\n- Opacity: opacity (0-1)\n\nEXAMPLES:\n- \"Create red text saying HELLO\" → itemType: \"" +
441
+ "text\", properties: {content: \"HELLO\", color: \"#ef4444\", fontSize: 48}\n- \"Add a blue circle\" → itemType: \"circle\", properties: {radius: 50, color: \"#3b82f6\"}\n- \"Create a 5-pointed gold star\" → itemType: \"star\", properties: {radius1: 60, radius2: 30, points: 5, color: \"#fbbf24\"}\n- \"Draw a heart\" → itemType: \"path\", properties: {pathData: \"M 300 350 C 300 300, 250 250, 200 250 C 150 250, 100 300, 100 350 C 100 450, 300 550, 300 550 C 300 550, 500 450, 500 350 C 500 300, 450 250, 400 250 C 350 250, 300 300, 300 350 Z\", fillColor: \"#ef4444\"}\n- \"Circle with radial gradient\" → itemType: \"circle\", properties: {radius: 50, color: {type: \"radial\", stops: [{color: " +
442
+ "\"#ff6b6b\", offset: 0}, {color: \"#c0392b\", offset: 1}]}}\n- \"Rectangle with shadow\" → itemType: \"rectangle\", properties: {width: 100, height: 50, color: \"#3b82f6\", shadowColor: \"rgba(0,0,0,0.5)\", shadowBlur: 10, shadowOffset: [5, 5]}\n\nITEM PROVENANCE:\nItems on the canvas can originate from multiple sources — all become first-class items with itemIds:\n- pinepaper_create_item: Direct shape creation (this tool)\n- pinepaper_import_svg: SVG file import → becomes a full item\n- pinepaper_import_image: Raster image import → becomes a full item\n- pinepaper_import_asset: Pre-made asset import → becomes a full item\nAll imported items participate in the relation and animation system" +
443
+ "s just like natively created items.\n\nRENDER-TIME SURFACES — 'shader' and 'field':\nThese are not shapes. A shape is built as an item and drawn from its geometry; a\nsurface has no geometry — it is evaluated, per pixel or per sample, as each frame\nis drawn. Use them for things a vector cannot be: an open sea, a drifting field\nof marks, a lit material.\n- shader: pick one of water | liquid_metal | heatmap | gem_smoke, give it width\n and height, and set behind:true to place it UNDER the vector art — which is\n what scenery wants, a sea the boats float ON rather than a sheen over them.\n- field: xExpr/yExpr/count draw many marks from one expression, re-evaluated\n every frame against t" +
444
+ ". rExpr and aExpr vary size and alpha per mark.\nBoth draw fully in a cloud render; on a local canvas they stand in as a flat\nplate of their own colour so the layout still reads.\n\nLIFETIMES — bornAt and ttl, on any item:\nThe only way to build a piece that CUTS between shots. Without them every item\nis on screen for the whole render, which is what makes a long piece read as one\ncrowded frame instead of a sequence.\n\nWORKFLOW TIP:\nAfter creating items, use pinepaper_add_relation to animate them (e.g., orbits, follows, attached_to).\nFor glossy 3D spheres, use pinepaper_create_glossy_sphere instead. For diagonal stripes, use pinepaper_create_diagonal_stripes."
445
445
 
446
446
 
447
447
 
@@ -1197,6 +1197,10 @@ Use pinepaper_clear_canvas instead of deleting items one by one.`,
1197
1197
  inputSchema: {
1198
1198
  type: 'object',
1199
1199
  properties: {
1200
+ opacity: { type: 'number', minimum: 0, maximum: 1, description: 'Opacity, 0 to 1. The same as pinepaper_modify_item after the import.' },
1201
+ blendMode: { type: 'string', description: "How it composites over what is below: 'multiply', 'screen' and the other pinepaper_modify_item blend modes." },
1202
+ scale: { type: 'number', exclusiveMinimum: 0, description: 'Multiplies the size maxWidth / maxHeight / maxEdge gave it.' },
1203
+ loop: { anyOf: [{ type: 'boolean' }, { type: 'integer', minimum: 1 }, { type: 'string', enum: ['forever'] }], description: "An animated GIF or WebP: true or 'forever' loops, false plays once and holds, n plays n times. Left out, the file's own loop count. Change it later with pinepaper_modify_item's loop, and its start time with imageStart." },
1200
1204
  url: {
1201
1205
  type: 'string',
1202
1206
  description: 'URL of the image to import',
@@ -1685,8 +1689,11 @@ EXAMPLE:
1685
1689
  },
1686
1690
  description: ("Manage the pp:event channel — the backbone of EVENT-DRIVEN SCENE CHAINS (FxTool S11). An event is a fireable channel; relations with an event as their source run when it fires. This is how you author a LONG scene as a graph of timed beats instead of a keyframe track.\n\nACTIONS:\n- create: make an event. { name (required), payloadType?, x?, y? } → returns { eventId }. Use the eventId as the sourceId of on_event_* relations (pinepaper_add_relation).\n- pulse: fire an event now. { eventId (required), payload? }. Kicks off a chain.\n\nTYPICAL SCENE CHAIN:\n1. pinepaper_event create → e0, e1, e2 (one per beat).\n2. Chain them: add_relation on_event_fire_after from e0→e1 (params.delay, timeline" +
1687
1691
  ":'canvas'), e1→e2, …\n3. Give each beat reactions: on_event_add_relation (spawn an orbit/path), on_event_set_color, on_event_set_property, on_event_set_visibility (all via pinepaper_add_relation, sourceId=the event).\n4. pinepaper_event pulse e0 to start. Canvas-timed fires are scrub/loop/replay-stable.\n\nRULES (contraptions): a rule runs when an event fires: { action: 'add_contraption', sourceEvent, actions, condition?, thenTrigger?, elseTrigger?, contraptionId? }. sourceEvent is an eventId, any name you pulse, or 'state:<key>' when that variable changes. actions: setVariable { key, value? }, incrementVariable { key, by? }, toggleVariable { key }, modifyItem { targetId, properties: { vis" +
1688
- "ible, opacity, fillColor, strokeColor, content, position } }. list_contraptions shows each rule's fireCount, which is how to tell it ran; remove_contraption takes contraptionId.\n- For a video, use on_event_* relations (above): a rule's changes to an item stay, nothing rewinds them, so another export starts from the changed scene. Rules suit interactive state.\n- pinepaper_clear_canvas removes rules. They are not saved with the scene.\n\nVARIABLES: set_variable { key, value }, get_variable { key, fallback? }, increment_variable { key, by? }, each with an optional scope. They live in the page's storage, so they survive pinepaper_clear_canvas: set the ones a scene reads when it starts. Nothi" +
1689
- "ng private belongs in them.\n\nBOUND TEXT: bind_text { itemId, template: \"Score: {score}\" } keeps a text item showing the variables it names.\n\nA studio without the contraption engine refuses these actions, naming the method it lacks.\n\nRETURNS: create → { success, action, eventId }; pulse → { success, action, eventId }; add_contraption → { success, action, contraptionId, warning? }."
1692
+ "ible, opacity, fillColor, strokeColor, content, position } }, toggleItemVisible { targetId }, animateItem { targetId, animation?, speed? }, playSound { preset?, note?, gain?, duration? }, playTimeline { duration?, loop? }. list_contraptions shows each rule's fireCount, which is how to tell it ran (a pulse runs its rules on the next frame); remove_contraption takes contraptionId.\n- In an export, what a rule changes is put back afterwards, so the next export starts from the same scene. animateItem cannot be put back, and the export warns. A change from a pulse outside an export stays.\n- An export is silent to playSound and refuses playTimeline, since it already steps the timeline. For soun" +
1693
+ "d in a video, use on_event_play_sound (above).\n- pinepaper_clear_canvas removes rules, and so does clear_contraptions. They are saved with the scene.\n\nVARIABLES: set_variable { key, value }, get_variable { key, fallback? }, increment_variable { key, by? }, each with an optional scope. They live in the page's storage, so they survive pinepaper_clear_canvas: set the ones a scene reads when it starts. Nothing private belongs in them.\n\nclear_variables { scope? } removes every variable in a scope, and no rule fires for it.\n\nBOUND TEXT: bind_text { itemId, template: \"Score: {score}\" } keeps a text item showing the variables it names.\n\nA studio without the contraption engine refuses th" +
1694
+ "ese actions, naming the method it lacks.\n\nRETURNS: create → { success, action, eventId }; pulse → { success, action, eventId }; add_contraption → { success, action, contraptionId, warning? }."
1695
+
1696
+
1690
1697
 
1691
1698
 
1692
1699
 
@@ -1710,7 +1717,7 @@ EXAMPLE:
1710
1717
  type: 'object',
1711
1718
  properties: {
1712
1719
  at: { description: 'create: fire this event at these scene times (seconds) — a timed trigger that runs on the timeline and in video exports. The widget player does not run timed triggers.', oneOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' } }] },
1713
- action: { type: 'string', enum: ['create', 'pulse', 'add_contraption', 'remove_contraption', 'list_contraptions', 'set_variable', 'get_variable', 'increment_variable', 'bind_text'], description: "'create' a pp:event · 'pulse' it to fire · 'add_contraption' / 'remove_contraption' / 'list_contraptions' a rule · 'set_variable' / 'get_variable' / 'increment_variable' a variable · 'bind_text' a text item to variables" },
1720
+ action: { type: 'string', enum: ['create', 'pulse', 'add_contraption', 'remove_contraption', 'list_contraptions', 'set_variable', 'get_variable', 'increment_variable', 'bind_text', 'clear_contraptions', 'clear_variables'], description: "'create' a pp:event · 'pulse' it to fire · 'add_contraption' / 'remove_contraption' / 'list_contraptions' a rule · 'set_variable' / 'get_variable' / 'increment_variable' a variable · 'bind_text' a text item to variables" },
1714
1721
  name: { type: 'string', description: "Event name — required for action 'create'" },
1715
1722
  payloadType: { type: 'string', description: "Payload type label (default 'Pulse') — action 'create'" },
1716
1723
  x: { type: 'number', description: 'Canvas x for the event marker — action create' },
@@ -1739,12 +1746,19 @@ EXAMPLE:
1739
1746
  items: {
1740
1747
  type: 'object',
1741
1748
  properties: {
1742
- type: { type: 'string', enum: ['setVariable', 'incrementVariable', 'toggleVariable', 'modifyItem'] },
1749
+ type: { type: 'string', enum: ['setVariable', 'incrementVariable', 'toggleVariable', 'modifyItem', 'toggleItemVisible', 'animateItem', 'playSound', 'playTimeline'] },
1743
1750
  key: { type: 'string', description: 'The variable' },
1744
1751
  value: { description: 'setVariable: left out, the event payload' },
1745
1752
  by: { type: 'number', description: 'incrementVariable: default 1' },
1746
1753
  scope: { type: 'string' },
1747
- targetId: { type: 'string', description: 'modifyItem: the item' },
1754
+ targetId: { type: 'string', description: 'modifyItem, toggleItemVisible, animateItem: the item' },
1755
+ animation: { type: 'string', description: "animateItem: an animationType. Default 'pulse'" },
1756
+ speed: { type: 'number', description: 'animateItem: default 1' },
1757
+ preset: { type: 'string', description: "playSound: default 'pop'" },
1758
+ note: { type: 'string', description: 'playSound' },
1759
+ gain: { type: 'number', description: 'playSound' },
1760
+ duration: { type: 'number', description: 'playSound; playTimeline: seconds, default 5' },
1761
+ loop: { type: 'boolean', description: 'playTimeline' },
1748
1762
  properties: {
1749
1763
  type: 'object',
1750
1764
  description: 'modifyItem: only these',
@@ -3538,13 +3552,13 @@ EXAMPLE: { itemId: 'item_7', color: '#00b140', threshold: 60, smoothing: 12 }`,
3538
3552
  "ton → event) + on_event_play_sound (event → null). For a sound at a TIME, pinepaper_event create { at: seconds | [seconds] } fires the event on the timeline (and in video exports); a scene chain's cutSound puts one on every cut. LIMITS: the exported widget player does not run timed triggers, and a browser may block sound until the viewer's first click or key.\n · on_event_store_set / on_event_store_increment / restores_from — persistent state. The target is a store node whose id is the storage SCOPE, so two stores in a scene and two widgets on a page do not collide. restores_from is the read half and fires ONCE at load.\n- Proximity (pointer over an item): on_enter_* / on_exit_* mirror th" +
3539
3553
  "e on_event_* reactions above (set_color, set_property, set_property_from_template, set_visibility, set_data, increment, toggle) — hover states without a single line of script.\n- Exclusive state: exclusive_group makes at most one member active — tabs, steppers, quiz cards. MEMBERSHIP is pairwise: bind exclusive_group from one member to each other member with the same relationOptions.groupName (default '_default'); nothing else adds an item to the group. Being active has NO LOOK OF ITS OWN: give each member on_enter_set_property / on_exit_set_property (say opacity 1 / 0.3), with the member as both source and target, and those reactions draw the change, in exports too. A current studio start" +
3540
3554
  "s the group with its first member entered (relationOptions.initial: '<itemId>' picks another, 'none' starts with none), firing its on_enter reactions at t=0; an older studio starts with none entered, so every member shows its un-entered look until the first step. on_event_set_active activates its target. on_event_step_active moves the active member by relationOptions.delta (default +1) for next / previous arrows, wrapping unless wrap: false; a current studio runs it with no target, an older one needs a targetId (any item, e.g. one member) and is refused without one, and its relationOptions.items only sets the step order of items already in the group. menubar_group is the same grouping WITH" +
3541
- "OUT the mutex.\n\nThere are 80 relation types and this list names the families rather than every member. For the live catalogue — what exists in THIS build, with definitions — call pinepaper_query_capabilities { kind: 'relation' }, which reads the engine's registry rather than a list written down here.\n\nRELATION COMPATIBILITY:\n- Universal relations (position-based) work with ALL item types including imported SVGs/images:\n orbits, follows, attached_to, maintains_distance, points_at, mirrors, parallax, bounds_to\n- morphs_to has type-specific behavior:\n Path→Path: point interpolation\n Circle→Circle: radius interpolation\n Text→Text: font blend\n Cross-type: particle denoising tran" +
3542
- "sition\n- 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.\n- 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, sampl" +
3543
- "es, scale, cx, cy, flipY, warp } (same equation contract as pinepaper_equation_path, but origin is cx/cy, default 0). y is MATH-UP by default (flipY: true): on screen the curve is mirrored about cy, so a curve written in screen coordinates (y down) needs flipY: false. Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).\n\nGEOMETRIC 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 " +
3544
- "target; extra anchors ride in params:\n- is_midpoint_of: source = midpoint(target, params.other). params.other = id of the second endpoint (B).\n- lies_on_line: source held on the line target→params.other at fraction params.t (0=A, 1=B; default 0.5). params.other (B), params.t.\n- is_centroid_of: source = centroid(target, ...params.others). params.others = array of the remaining point ids.\n- is_circumcenter_of: source = circumcenter(target, params.other1, params.other2); holds position when the three anchors are collinear. params.other1 (B), params.other2 (C).\n- concentric_with: source stays centered on the target (shared center).\n- construction_reveal: self-relation (targetId=null); op" +
3545
- "acity 0→1 starting at params.revealAt (timeline seconds) over params.fadeIn seconds — loop- and scrub-correct. Pair with pinepaper_construction_sequence to reveal a figure one step at a time.\n\nPROCEDURAL / DETERMINISTIC BINDING (Expression IR — S10 G1) — bind a property to another property or to a math function of time. Pass params.signal:true to route through the pure, seed/frame-deterministic signal interpreter (replay-stable — same time → identical output); omit it for the per-frame compute fallback.\n- driven_by: source's sourceProperty = target's targetProperty * multiplier + offset (optionally clamped). sourceProperty ∈ x|y|rotation|opacity|scale|strokeWidth|fillColor|strokeColor; " +
3546
- "targetProperty ∈ x|y|rotation|opacity|scale. For fillColor/strokeColor the (clamped 0..1) driven value interpolates colorFrom→colorTo — so a relation drives COLOR. NEEDS a target (the property source). params: sourceProperty, targetProperty, multiplier, offset, clamp {min,max}, colorFrom, colorTo, signal. e.g. tint a dot by a planet's x: sourceProperty 'fillColor', targetProperty 'x', colorFrom '#0055ff', colorTo '#ff3300', signal true.\n- time_expression: self-relation (targetId=null); the source's params.property (x, y, rotation, opacity or scale) is driven by a formula of t (scene time) and v (params.baseValue). params: property (default 'y'), expression (e.g. 'sin(t*2)*50 + v'), baseVa" +
3547
- "lue, signal (true also evaluates it as a pure f(t) via the Expression IR). The formula grammar: " +
3555
+ "OUT the mutex.\n\nThere are 80 relation types and this list names the families rather than every member. For the live catalogue — what exists in THIS build, with definitions — call pinepaper_query_capabilities { kind: 'relation' }, which reads the engine's registry rather than a list written down here.\n\nRELATION COMPATIBILITY:\n- Universal relations (position-based) work with ALL item types including imported SVGs/images:\n orbits, follows, attached_to, maintains_distance, points_at, mirrors, parallax, bounds_to\n- morphs_to has type-specific behavior:\n Path→Path: point interpolation\n Circle→Circle: radius interpolation\n Text→Text: glyph by glyph; shared glyphs travel, the rest cr" +
3556
+ "oss-dissolve. params.mode: 'auto' (default), 'glyphs' (the same), 'dissolve' or 'particles' (the whole item cross-fades). Any other mode is refused.\n Cross-type: particle denoising transition\n- 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.\n- 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" +
3557
+ " 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). y is MATH-UP by default (flipY: true): on screen the curve is mirrored about cy, so a curve written in screen coordinates (y down) needs flipY: false. Params: path OR equation, speed, closed, phase, easing (linear|easeIn|easeOut|easeInOut|sine|bounce|pingpong).\n\nGEOMETRIC CONSTRUCTION CONSTRAINTS (Layer 2) — the source item is RE-DERIVED every frame from its anchor item(s), so dragging an" +
3558
+ " 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:\n- is_midpoint_of: source = midpoint(target, params.other). params.other = id of the second endpoint (B).\n- lies_on_line: source held on the line target→params.other at fraction params.t (0=A, 1=B; default 0.5). params.other (B), params.t.\n- is_centroid_of: source = centroid(target, ...params.others). params.others = array of the remaining point ids.\n- is_circumcenter_of: source = circumcenter(target, params.other1, params.other2); holds position when the three anch" +
3559
+ "ors are collinear. params.other1 (B), params.other2 (C).\n- concentric_with: source stays centered on the target (shared center).\n- construction_reveal: self-relation (targetId=null); opacity 0→1 starting at params.revealAt (timeline seconds) over params.fadeIn seconds — loop- and scrub-correct. Pair with pinepaper_construction_sequence to reveal a figure one step at a time.\n\nPROCEDURAL / DETERMINISTIC BINDING (Expression IR — S10 G1) — bind a property to another property or to a math function of time. Pass params.signal:true to route through the pure, seed/frame-deterministic signal interpreter (replay-stable — same time → identical output); omit it for the per-frame compute fallback." +
3560
+ "\n- driven_by: source's sourceProperty = target's targetProperty * multiplier + offset (optionally clamped). sourceProperty ∈ x|y|rotation|opacity|scale|strokeWidth|fillColor|strokeColor; targetProperty ∈ x|y|rotation|opacity|scale. For fillColor/strokeColor the (clamped 0..1) driven value interpolates colorFrom→colorTo — so a relation drives COLOR. NEEDS a target (the property source). params: sourceProperty, targetProperty, multiplier, offset, clamp {min,max}, colorFrom, colorTo, signal. e.g. tint a dot by a planet's x: sourceProperty 'fillColor', targetProperty 'x', colorFrom '#0055ff', colorTo '#ff3300', signal true.\n- time_expression: self-relation (targetId=null); the source's param" +
3561
+ "s.property (x, y, rotation, opacity or scale) is driven by a formula of t (scene time) and v (params.baseValue). params: property (default 'y'), expression (e.g. 'sin(t*2)*50 + v'), baseValue, signal (true also evaluates it as a pure f(t) via the Expression IR). The formula grammar: " +
3548
3562
  String(expressionGrammarText()) +
3549
3563
  ". A name outside it, or a wrong argument count, is refused by name: it would parse to nothing and the item would not move.\n\nEVENT-DRIVEN SCENE CHAINS (S11) — build a LONG scene as a graph of timed events, not a keyframe track. First make events with pinepaper_event (action:'create' → eventId). The relations below have an EVENT as their sourceId; firing the event runs the reaction. Chain beats over animation time with on_event_fire_after (timeline:'canvas'), have beats mutate the scene with on_event_add_relation, then kick it off with pinepaper_event action:'pulse'. Because canvas-timed fires are on the animation clock, the whole chain is scrub-, loop-, and replay-stable.\n- on_event_fire" +
3550
3564
  "_after: sourceId=event, targetId=the NEXT event. When source fires, pulse target after params.delay ms. params.timeline: 'canvas' (animation clock — use for scene chains) | 'wall' (real setTimeout). This is the chaining primitive.\n- on_event_fire_if: sourceId=event, targetId=any item (it is not fired). params.event = the event to pulse when params.predicate holds, params.else = the one when it does not. A branch is two relations: A —on_event_fire_if { predicate, event: B }→ any item; B —on_event_set_color→ lamp. Example predicate, \"score is at least 3\": { op:'cmp', cmp:'gte', a:{ op:'graph', read:'data', item:'item_3', property:'score' }, b:{ op:'const', value:3 } }.\n- on_event_add_rel" +
@@ -3552,8 +3566,8 @@ String(expressionGrammarText()) +
3552
3566
  "ourceId=event, targetId=item. params { visible } (default true; false hides). params.value is not read: it is refused.\n\nSTRUCTURAL LAYOUT (S12) — express STATIC COMPOSITION as relations instead of hardcoded x/y. Placement is computed from the TARGET'S BOUNDS and re-derived every frame, so moving or resizing the target moves the dependent with it, and the layout survives as editable graph data. Reach for these whenever the intent is \"on\", \"under\", \"next to\", \"inside\", or \"lined up with\" — a create_item with literal coordinates cannot express that intent and silently breaks when the target moves. All six need a target; all accept params.signal (deterministic IR port) and params.w" +
3553
3567
  "indow.\n- on_top_of: source's BOTTOM edge rests on the target's TOP edge — stacking (a cocktail on a bar). params { gap (px between edges, default 0), align: 'left'|'center'(default)|'right', overhang (extra signed x-offset) }.\n- below: mirror of on_top_of — source's TOP edge rests on the target's BOTTOM edge. params { gap, align, overhang }.\n- beside: source flanks the target horizontally. params { side: 'left'|'right' (default 'right'), gap, align: 'top'|'center'(default)|'bottom' }.\n- inside: source is placed within the target's bounds at a 9-way anchor. params { anchor: 'center'(default)|'top-left'|'top'|'top-right'|'left'|'right'|'bottom-left'|'bottom'|'bottom-right', padding (inse" +
3554
3568
  "t from the target edge) }. Contrast bounds_to, which CLAMPS a moving item; inside PLACES it.\n- centered_on: source center = target center + offset. params { offsetX, offsetY } — concentric rings at zero offset.\n- aligned_with: matches the target's center on ONE axis, leaving the other free (a partial write — use it to column-align a label while it still moves vertically). params { axis: 'x'|'y' — REQUIRED, the relation is inert without it, offset }.\n\nCURSOR / MOUSE AS TARGET — a relation can target the live pointer instead of an item by passing the reserved targetId 'cursor' (aliases 'mouse' / 'pointer'); collaborator cursors are 'cursor:<peerId>'. e.g. make an item flee the mouse: rel" +
3555
- "ationType 'repels', targetId 'cursor', params { returnSpeed: 0.08 }. The cursor is a virtual target (no canvas item) and its interactions are inspectable in FxTool's \"Mouse / Cursor\" registry section.\n\nTEMPORAL VALIDITY WINDOWS (temporal-model v1) — ANY relation may carry params.window = { start, end?, repeat? } (scene seconds) to gate WHEN it is active. The engine only applies the relation within [start, end) and feeds it window-relative local time (so an orbit/wave starts its phase at the window open, not at scene 0). repeat ∈ once (default) | loop | pingpong. Omit end for open-ended. Windowless relations are always active. e.g. params: { ...relationParams, window: { start: 2, end: 5" +
3556
- " } } makes the relation live only from 2s to 5s.\n\nVERIFYING ANIMATION WORKS:\nAfter adding a relation, verify the animation is running:\n1. Use pinepaper_browser_screenshot to see the canvas\n2. Wait 1-2 seconds and take another screenshot - items should have moved\n3. Use pinepaper_query_relations to confirm the relation was added\n\nRelations are COMPOSITIONAL - an item can have multiple relations that work together!"
3569
+ "ationType 'repels', targetId 'cursor', params { returnSpeed: 0.08 }. The cursor is a virtual target (no canvas item) and its interactions are inspectable in FxTool's \"Mouse / Cursor\" registry section.\n\nTEMPORAL VALIDITY WINDOWS (temporal-model v1) — ANY relation may carry params.window = { start, end?, repeat? } (scene seconds) to gate WHEN it is active. The engine only applies the relation within [start, end) and feeds it window-relative local time (so an orbit/wave starts its phase at the window open, not at scene 0). repeat ∈ once (default) | loop | pingpong. Omit end for open-ended. Windowless relations are always active. An on_event_* reaction fires outside this check, so a window" +
3570
+ " does not gate it. e.g. params: { ...relationParams, window: { start: 2, end: 5 } } makes the relation live only from 2s to 5s.\n\nVERIFYING ANIMATION WORKS:\nAfter adding a relation, verify the animation is running:\n1. Use pinepaper_browser_screenshot to see the canvas\n2. Wait 1-2 seconds and take another screenshot - items should have moved\n3. Use pinepaper_query_relations to confirm the relation was added\n\nRelations are COMPOSITIONAL - an item can have multiple relations that work together!"
3557
3571
 
3558
3572
 
3559
3573
 
@@ -3964,10 +3978,11 @@ USE WHEN:
3964
3978
  description: ("Apply keyframe-based animation with precise timing and property control.\n\nUSE WHEN:\n- \"fade in over 3 seconds\"\n- \"move from left to right in 2 seconds\"\n- \"change color from red to blue\"\n- \"first fade in, then rotate, then fade out\"\n- Any animation with specific timing or sequential stages\n\nBEFORE AUTHORING DENSE TRACKS — three routes are cheaper, and two of them keep the scene describable. Measured on the same 63-item, 32-keyframes-each scene:\n- Motion that is a FUNCTION OF TIME (bob, pulse, sweep, phase-offset wave) → pinepaper_add_relation with relationType 'time_expression'. One call per property instead of one keyframe per sample: 26k characters against 147k. It also " +
3965
3979
  "survives into the design graph, save/reload and widget export, which keyframes generated from a formula do not describe.\n- PER-ITEM VARIATION across many items → staggered_with or wave_through express the whole group in ONE call. That collapses the 63-fold repetition a per-item time_expression still pays for.\n- GENUINELY PROCEDURAL scenes → pinepaper_execute_custom_code with app.create + app.addAnimation (which attaches keyframe tracks programmatically, so the keyframes are generated rather than typed), at 4k characters for the same scene — cheapest by an order of magnitude, because a loop collapses the repetition entirely. The trade is real and worth making deliberately: raw JS leaves N" +
3966
3980
  "O behavioral record — no graph edge, nothing SMIL or widget export can lift, nothing the ontology can read. Scene building fits the governor's 4s synchronous deadline easily (it is the injected loop guards, not the 10s async-tail budget, that bites a build loop).\n\n- TEXT THAT CHANGES OVER TIME -> 'content' IS keyframeable, and it is DISCRETE: it holds the current word until the next keyframe and does not tween. One thing worth knowing before you build a cascade out of it. A cascade of N words is N keyframes you have to write out, which is the reason to prefer the call below rather than any limitation. For the common case prefer pinepaper_execute_custom_code with app.textSequence(itemOrId" +
3967
- ", words, { interval }): ONE item cycling its words in one call, against one item per word with opacity tracks faking the cut. It returns { ok: false, reason } on an id it cannot resolve.\n\nKeyframes are for motion that is AUTHORED, not computed. A track of hundreds of entries produced from a formula is the signal that one of the three above is the right route.\n\nANIMATABLE PROPERTIES:\n- position: [x, y] array (a { x, y } object is also accepted and normalized)\n- x, y: Individual coordinates\n- scale: Uniform scale\n- scaleX, scaleY: Axis scaling\n- rotation: Degrees\n- opacity: 0-1\n- fillColor: Color string\n- strokeColor: Color string\n- strokeWidth, width, height, radius, shadowBlur" +
3968
- ", shadowColor: Numbers / colours\n- fontSize: Number\n- skewX, skewY (current studio): degrees, clamped to ±89; they combine with rotation and scale. An older studio stores them without drawing and the result names them in ignoredProperties; there, a shear is a transform matrix track.\n- TEXT (current studio): fontStyle ('normal' / 'italic' / 'oblique', discrete — switches at the next key), textAlign ('left' / 'center' / 'right', discrete; the anchor stays put), lineHeight (px, or a multiple of fontSize up to 5), letterSpacing (px, or a CSS length such as '0.1em'), wordSpacing (px or a CSS length), textTransform and textDecoration (discrete). An older studio stores these keys without drawi" +
3969
- "ng them; the result names any it did not animate (ignoredProperties). fontWeight keys STEP: a font with fixed weights switches from one weight to the next once, partway through the move, and does not ramp. Key fontSize or scale for a smooth change.\n- 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.\nThe authoritative list for a given item is pinepaper_get_animatable_properties — ask it rather than guessing.\n\nEASING — BY DEFAULT IT SHAPES THE SEGMENT ARRIVING AT THE KEYFRAME IT IS ON.\nThe move from keyframe A to keyframe B is eased by B's easing, not A's. So by default the FIRS" +
3970
- "T keyframe's easing has no effect (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, or pass easingAt: 'departure' for the other reading: each key's easing shapes the move LEAVING it, so the first key's easing eases the first move (current studio; an older one reads arrival and the result says so). 'hold' / 'step' stay on the key they mark either way.\n" +
3981
+ ", words, { interval }): ONE item cycling its words in one call, against one item per word with opacity tracks faking the cut. It returns { ok: false, reason } on an id it cannot resolve.\n\nKeyframes are for motion that is AUTHORED, not computed. A track of hundreds of entries produced from a formula is the signal that one of the three above is the right route.\n\nANIMATABLE PROPERTIES:\n- position: [x, y] array (a { x, y } object is also accepted and normalized)\n- x, y: Individual coordinates\n- scale: Uniform scale\n- scaleX, scaleY: Axis scaling\n- rotation: Degrees. Keys interpolate as plain numbers, with no short way round: 350 to 10 sweeps back through 180. Key 350 to 370 to cross z" +
3982
+ "ero.\n- opacity: 0-1\n- fillColor: Color string\n- strokeColor: Color string\n- strokeWidth, width, height, radius, shadowBlur, shadowColor: Numbers / colours\n- fontSize: Number\n- skewX, skewY (current studio): degrees, clamped to ±89; they combine with rotation and scale. An older studio stores them without drawing and the result names them in ignoredProperties; there, a shear is a transform matrix track.\n- TEXT (current studio): fontStyle ('normal' / 'italic' / 'oblique', discrete — switches at the next key), textAlign ('left' / 'center' / 'right', discrete; the anchor stays put), lineHeight (px, or a multiple of fontSize up to 5), letterSpacing (px, or a CSS length such as '0.1em'), " +
3983
+ "wordSpacing (px or a CSS length), textTransform and textDecoration (discrete). An older studio stores these keys without drawing them; the result names any it did not animate (ignoredProperties). fontWeight keys STEP: a font with fixed weights switches from one weight to the next once, partway through the move, and does not ramp. Key fontSize or scale for a smooth change.\n- 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.\nThe authoritative list for a given item is pinepaper_get_animatable_properties — ask it rather than guessing.\n\nEASING — BY DEFAULT IT SHAPES THE SEGMENT ARRI" +
3984
+ "VING AT THE KEYFRAME IT IS ON. It belongs to the key, not to one property: 'step' on a key holds every property in that key, so keep a stepped property in its own call from one that moves.\nThe move from keyframe A to keyframe B is eased by B's easing, not A's. So by default the FIRST keyframe's easing has no effect (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, or pass easingAt: 'departure' for the other reading: each key's easing shapes the move LEAVING it, so the first key's easing eases the first move (current studio; an older one reads arrival and the result says so). 'hold' / 'step' stay on the key" +
3985
+ " they mark either way.\n" +
3971
3986
  String(EASING_LINES) +
3972
3987
  "\n- or a custom cubic-bezier [x1, y1, x2, y2] — x in 0..1, y may exceed 0..1 for overshoot. keyframe_animate and camera_animate only.\nCOUNTERS: a text item with a counter (create_item / modify_item counter) also animates the numeric property counterValue, drawn through the counter's format — keyframe it to shape a count by hand (hold on a number, then race). Its keys override the counter's own from / to.\n\nHOLD KEYS (where the studio has them): interpolation: 'hold' on a key holds ITS value until the next key, then cuts — no in-between. Easing 'hold' / 'step' on the ARRIVING key does the same. A studio without hold keys refuses rather than interpolate; there, two keys a frame apart make " +
3973
3988
  "the cut. For a whole layer 'on twos', set stepTiming on the item (create_item / modify_item). A DISCRETE change (a bar turning red at 2 s, a label switching colour) needs this too: without hold / step every key ramps from the one before it, so the colour fades across the whole gap instead of switching. This applies to batch keyframe ops the same way.\n\nEvery easing renders exactly in video, GIF and APNG. Lottie and CSS get the closest single Bézier: springPlayful loses its wobble, and the in-out quart/quint/expo/circ curves are within 2–4 %. SMIL is exact for springs and those four.\n\nCLIP WINDOW (optional — Premiere/Canva-style clip behavior):\n- timeOffset: Shift the whole clip in canv" +
@@ -4010,7 +4025,6 @@ String(EASING_LINES) +
4010
4025
 
4011
4026
 
4012
4027
 
4013
-
4014
4028
 
4015
4029
 
4016
4030
  ),
@@ -8135,12 +8149,12 @@ String(STYLED_SCENE_STYLES.map((k) => `${k} (${STYLED_SCENE_STYLE_INFO[k]})`).jo
8135
8149
  {
8136
8150
  name: 'pinepaper_emitter',
8137
8151
  annotations: { title: 'Particle Emitter', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
8138
- description: ("Particles as an ITEM whose every frame is a pure function of its spec and the scene time — so the preview, capture_frames, every export, a saved project, a template and the cloud render all draw the same particles, scrubbing backwards included. The serialisable, cloud-safe choice; pinepaper_apply_effect is a live look on an item.\n\nACTIONS:\n- create: { spec } → itemId\n- set: { itemId, spec } — merge a partial spec onto the emitter's.\n\nSPEC:\n- mode: 'burst' (default) — bursts: [{ t, x, y, count, …per-burst overrides }]: a pop of particles at scene time t.\n- mode: 'trail' (or give target) — target (an item id), rate (per second), emitWindows [[t0, t1], …], window, offset: particles sh" +
8139
- "ed from a moving item.\n- look and physics (per emitter or per burst): shape ('circle' | 'rect' | 'dash' | 'star' | 'confetti', or an array), palette [colours], size / sizes / sizeEnd, speed + angle ranges or velocities ([[vx, vy], …] px/s, or [{x, y}, …]; particle i takes velocities[i % length], so give one per particle — the reply warns when count and the list differ), gravity (px/s², +down), drag, friction, lifetime, fadeIn / fadeOut, spin, opacity, jitter, jitterGrow, seed.\nUNITS (a number, or a [min, max] range picked per particle where it says range):\n- count: particles per burst, at most 10000 on the current studio (2000 on older studios) — a larger count is drawn at the cap and t" +
8140
- "he reply warns with the number drawn. For more, add a second burst at the same t.\n- speed: px/s (range). angle: degrees, 0 = right, 90 = down (range). gravity: px/s². lifetime: seconds (range, > 0). size: px (range).\n- gravity, drag, friction, opacity, sizeEnd and jitter also take a range on the current studio, sampled per particle; an older studio draws the midpoint and warns emitter-range-not-read.\n- fadeIn / fadeOut: SECONDS at the start / end of each particle's life. fadeOut is capped at that particle's lifetime (a 0.5 s life cannot fade for 2 s; it fades over its whole life instead), and the current studio warns when it caps one. Omitted (or fade: 'linear'), the particle fades line" +
8141
- "arly over its WHOLE life; fade: 'none' turns both off.\n- spin: DEGREES PER SECOND, positive = clockwise on screen (range; spinAlternate flips every other particle). It turns rect, confetti, star and dash particles; a circle has no orientation, so spin on circles shows nothing (the current studio warns when every particle is a circle).\n- jitter: PIXELS — on the current studio each particle is displaced up to ±jitter px on each axis from its path, at full size from birth; jitterGrow: true keeps the older ramp (zero at birth, the full ±jitter at the end of life). Older studios (before jitterGrow) displace up to ±jitter/2, growing from 0 at birth. jitterHz: how many times a second that offse" +
8142
- "t is re-rolled (0, the default, keeps one offset per particle).\nAn unknown shape, mode or bad range is refused with the engine's reason — never defaulted.\n\nEXAMPLE — confetti at 2 s:\n{ action: 'create', spec: { bursts: [{ t: 2, x: 960, y: 400, count: 80 }], shape: 'confetti', palette: ['#f43f5e', '#facc15', '#22d3ee'], speed: [300, 700], angle: [-150, -30], gravity: 900, lifetime: 2.5, seed: 7 } }"
8143
-
8152
+ description: ("Particles as an ITEM whose every frame is a pure function of its spec and the scene time — so the preview, capture_frames, every export, a saved project, a template and the cloud render all draw the same particles, scrubbing backwards included. The serialisable, cloud-safe choice; pinepaper_apply_effect is a live look on an item.\n\nACTIONS:\n- create: { spec } → itemId\n- set: { itemId, spec } — merge a partial spec onto the emitter's.\n\nSPEC:\n- mode: 'burst' (default) — bursts: [{ t, x, y, count, …per-burst overrides }]: a pop of particles at scene time t.\n- mode: 'trail' (or give target) — target (an item id), rate (per second), emitWindows [[t0, t1], …] (when it sheds), window [t0, " +
8153
+ "t1] (when the WHOLE trail shows: it switches on and off at those times, with what was shed before t0 already there), offset ([dx, dy] or {x, y}): particles shed from a moving item. rate, start and end are trail settings: a burst emitter does not read them.\n- look and physics (per emitter or per burst): shape ('circle' | 'rect' | 'dash' | 'star' | 'confetti', or an array; size is a circle's diameter, a rect's or confetti's width (height is half of it), and a star's extent is 0.8 of it; a dash is a thin line drawn forward from the particle along its velocity), palette [colours], size / sizes / sizeEnd, speed + angle ranges or velocities ([[vx, vy], …] px/s, or [{x, y}, …]; particle i takes " +
8154
+ "velocities[i % length], so give one per particle — the reply warns when count and the list differ), gravity (px/s², +down), drag (slows the launch velocity only: what gravity adds is not damped, so a falling particle has no terminal velocity), friction, lifetime, fadeIn / fadeOut, spin, opacity, jitter, jitterGrow, seed.\nUNITS (a number, or a [min, max] range picked per particle where it says range):\n- count: particles per burst, at most 10000 on the current studio (2000 on older studios) — a larger count is drawn at the cap and the reply warns with the number drawn. For more, add a second burst at the same t.\n- speed: px/s (range). angle: degrees, 0 = right, 90 = down (range). gravity:" +
8155
+ " px/s². lifetime: seconds (range, > 0). size: px (range).\n- gravity, drag, friction, opacity, sizeEnd and jitter also take a range on the current studio, sampled per particle; an older studio draws the midpoint and warns emitter-range-not-read.\n- fadeIn / fadeOut: SECONDS at the start / end of each particle's life. fadeOut is capped at that particle's lifetime (a 0.5 s life cannot fade for 2 s; it fades over its whole life instead), and the current studio warns when it caps one. Omitted (or fade: 'linear'), the particle fades linearly over its WHOLE life; fade: 'none' turns both off.\n- spin: DEGREES PER SECOND, positive = clockwise on screen (range; spinAlternate flips every other parti" +
8156
+ "cle). It turns rect, confetti, star and dash particles; a circle has no orientation, so spin on circles shows nothing (the current studio warns when every particle is a circle).\n- jitter: PIXELS — on the current studio each particle is displaced up to ±jitter px on each axis from its path, at full size from birth; jitterGrow: true keeps the older ramp (zero at birth, the full ±jitter at the end of life). Older studios (before jitterGrow) displace up to ±jitter/2, growing from 0 at birth. jitterHz: how many times a second that offset is re-rolled (0, the default, keeps one offset per particle).\nAn unknown shape, mode or bad range is refused with the engine's reason — never defaulted.\n\nE" +
8157
+ "XAMPLE — confetti at 2 s:\n{ action: 'create', spec: { bursts: [{ t: 2, x: 960, y: 400, count: 80 }], shape: 'confetti', palette: ['#f43f5e', '#facc15', '#22d3ee'], speed: [300, 700], angle: [-150, -30], gravity: 900, lifetime: 2.5, seed: 7 } }"
8144
8158
 
8145
8159
 
8146
8160
 
@@ -8816,9 +8830,9 @@ String(KEYFRAME_EASINGS_LIST) +
8816
8830
  "low a multi-point path the user lists → relation moves_along_path {params:{path:[{x,y},…]}}.\n\nEFFECTS:\n apply_mask — Reveal effect: {itemId, maskPreset, maskOptions}\n maskPreset: wipeLeft, wipeRight, wipeUp, wipeDown, iris, irisOut, star, heart, curtainHorizontal, curtainVertical, cinematic, diagonalWipe, revealUp, revealDown\n apply_effect — Visual effect: {itemId, effectType, effectParams}\n effectType: " +
8817
8831
  String(EFFECT_TYPES_LIST) +
8818
8832
  "\n\nPLAYBACK:\n play_timeline — {action: \"play\"|\"stop\"|\"seek\", duration, loop}\n\nVARIABLE REFERENCES: \"$0\", \"$1\" etc. reference the items this batch makes, in op order: create and group ops each take the next number (a ring / grid / orbit that makes items takes one per item); no other op does. [create, create, group, …] makes the group \"$2\". Place the ops that make an item before any op that references it.\n\nANIMATION NOTES:\n- keyframe_animate position accepts [x, y] or { x, y } (normalized). fillColor/strokeColor accept rgba() for alpha. The create \"opacity\" and \"blendMode\" props are applied to the item.\n- For MP4/GIF export the keyframe timeline is rendered once acro" +
8819
- "ss the duration; author motion to fill the clip and use loop:false. Looping item-animations (loop:true) are not advanced by the exporter and render frozen.\n\nEXAMPLE — Animated sky scene with timed reveals:\n{operations: [\n {type: \"set_canvas_size\", preset: \"full-hd-1080p\"},\n {type: \"execute_generator\", generatorName: \"drawGradientMesh\", generatorParams: {colors: [\"#1e3a5f\", \"#87CEEB\", \"#fbbf24\"], bgColor: \"#0c1445\", blobCount: 4}},\n {type: \"create\", itemType: \"circle\", position: {x: 1600, y: 150}, properties: {radius: 80, color: \"#fbbf24\", shadowColor: \"#fbbf24\", shadowBlur: 40}},\n {type: \"create\", itemType: \"ellipse\", position: {x: 400, y: 180}, prope" +
8820
- "rties: {radiusX: 100, radiusY: 35, color: \"#ffffff\", opacity: 0.8}},\n {type: \"create\", itemType: \"ellipse\", position: {x: 900, y: 120}, properties: {radiusX: 120, radiusY: 40, color: \"#ffffff\", opacity: 0.7}},\n {type: \"create\", itemType: \"text\", position: {x: 960, y: 500}, properties: {content: \"Dream Big\", fontSize: 96, color: \"#ffffff\", fontWeight: \"bold\"}},\n {type: \"animate\", itemId: \"$0\", animationType: \"pulse\", animationOptions: {speed: 0.3}},\n {type: \"animate\", itemId: \"$1\", animationType: \"slideLeftRight\", animationOptions: {speed: 0.2}},\n {type: \"animate\", itemId: \"$2\", animationType: \"slide\", animationOptions: {speed: 0.15, direction: " +
8821
- "\"left\"}},\n {type: \"keyframe_animate\", itemId: \"$3\", keyframes: [{time: 0, properties: {opacity: 0, scale: 0.8}}, {time: 2, properties: {opacity: 1, scale: 1}, easing: \"easeOutBack\"}], duration: 3},\n {type: \"apply_mask\", itemId: \"$3\", maskPreset: \"wipeLeft\"},\n {type: \"play_timeline\", action: \"play\", duration: 5, loop: true}\n]}"
8833
+ "ss the duration; author motion to fill the clip and use loop:false. \n\nEXAMPLE — Animated sky scene with timed reveals:\n{operations: [\n {type: \"set_canvas_size\", preset: \"full-hd-1080p\"},\n {type: \"execute_generator\", generatorName: \"drawGradientMesh\", generatorParams: {colors: [\"#1e3a5f\", \"#87CEEB\", \"#fbbf24\"], bgColor: \"#0c1445\", blobCount: 4}},\n {type: \"create\", itemType: \"circle\", position: {x: 1600, y: 150}, properties: {radius: 80, color: \"#fbbf24\", shadowColor: \"#fbbf24\", shadowBlur: 40}},\n {type: \"create\", itemType: \"ellipse\", position: {x: 400, y: 180}, properties: {radiusX: 100, radiusY: 35, color: \"#ffffff\", opacity: 0.8}},\n {type: \"crea" +
8834
+ "te\", itemType: \"ellipse\", position: {x: 900, y: 120}, properties: {radiusX: 120, radiusY: 40, color: \"#ffffff\", opacity: 0.7}},\n {type: \"create\", itemType: \"text\", position: {x: 960, y: 500}, properties: {content: \"Dream Big\", fontSize: 96, color: \"#ffffff\", fontWeight: \"bold\"}},\n {type: \"animate\", itemId: \"$0\", animationType: \"pulse\", animationOptions: {speed: 0.3}},\n {type: \"animate\", itemId: \"$1\", animationType: \"slideLeftRight\", animationOptions: {speed: 0.2}},\n {type: \"animate\", itemId: \"$2\", animationType: \"slide\", animationOptions: {speed: 0.15, direction: \"left\"}},\n {type: \"keyframe_animate\", itemId: \"$3\", keyframes: [{time: 0, prope" +
8835
+ "rties: {opacity: 0, scale: 0.8}}, {time: 2, properties: {opacity: 1, scale: 1}, easing: \"easeOutBack\"}], duration: 3},\n {type: \"apply_mask\", itemId: \"$3\", maskPreset: \"wipeLeft\"},\n {type: \"play_timeline\", action: \"play\", duration: 5, loop: true}\n]}"
8822
8836
 
8823
8837
 
8824
8838
 
@@ -9081,9 +9095,9 @@ String(EFFECT_TYPES_LIST) +
9081
9095
  },
9082
9096
  description: ("Smart export with automatic format detection and platform optimization.\n\nWHAT 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.\nThe compression half is weaker than its name for VIDEO: on mp4 / webm it scales the encoder's resolution-derived target bitrate, and the browser encoder treats that target as a CEILING. A simple scene encodes well under either ceiling, so draft and high at the same" +
9083
9097
  " fps can come out within bytes of each other; a scene with grain is encoded at a fixed quantizer and ignores it. To change a video's size or quality, use scale, or bitrate (with bitrateMode 'constant' to hold it); result.video.achievedBitrate says what the encoder produced. On jpg / webp stills compression IS the encoder quality, and lower means a smaller file.\n\nDOES 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 " +
9084
- "NOT survive is anything driven by the wall clock outside that loop, or anything on the realtime recorder path.\nONE 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.\n\nSEAMLESS 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.\n\nIf exp" +
9085
- "orted 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.\n\nPLATFORMS & OPTIMAL FORMATS:\n| Platform | Dimensions | Static | Animated |\n|-----------------|------------|--------|----------|\n| instagram | 1080x1080 | PNG | MP4 |\n| instagram-story | 1080x1920 | PNG | MP4 |\n| tiktok | 1080x1920 | PNG | MP4@60fps|\n| youtube | 1920x1080 | PNG | MP4 |\n| twitter | 1200x675 | PNG | GIF |\n| web | flexible | SVG " +
9086
- " | SVG |\n\nA 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.\n\nFORMATS: " +
9098
+ "NOT survive is anything driven by the wall clock outside that loop, or anything on the realtime recorder path.\n\nSEAMLESS 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.\n\nIf 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.\n\nPLATFORMS & OPTIMAL FORMATS:\n| Pla" +
9099
+ "tform | Dimensions | Static | Animated |\n|-----------------|------------|--------|----------|\n| instagram | 1080x1080 | PNG | MP4 |\n| instagram-story | 1080x1920 | PNG | MP4 |\n| tiktok | 1080x1920 | PNG | MP4@60fps|\n| youtube | 1920x1080 | PNG | MP4 |\n| twitter | 1200x675 | PNG | GIF |\n| web | flexible | SVG | SVG |\n\nA 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.\n\n" +
9100
+ "FORMATS: " +
9087
9101
  String(AgentExportFormatSchema.options.join(', ')) +
9088
9102
  ".\nQUALITY: draft, standard, high.\nFRAMING: 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).\n\n⚠️ If export fails, do NOT retry with different formats — report the error to the user.\n⚠️ When user asks for \"video\", always use format: \"mp4\".\n\nEXAMPLES:\n- {platform: \"instagram\"} — auto-selects best format\n- {platform: \"instagram\", format: \"mp4\"} — force MP4\n- {platform: \"web\", format: \"svg\"} — animated SVG\n- {platform: \"youtube\", format: \"mp4\", framing: \"camera\"} — render only what the camera " +
9089
9103
  "frames during the walkthrough\n- {platform: \"youtube\", format: \"mp4\", duration: 30, estimateOnly: true} — how big would that be, and what would it lose? (renders nothing)\n\nEvery 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.\n\nVERIFY 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" +
@@ -9116,7 +9130,6 @@ String(AgentExportFormatSchema.options.join(', ')) +
9116
9130
 
9117
9131
 
9118
9132
 
9119
-
9120
9133
 
9121
9134
 
9122
9135
  ),
@@ -9203,7 +9216,7 @@ String(AgentExportFormatSchema.options.join(', ')) +
9203
9216
  },
9204
9217
  fps: {
9205
9218
  anyOf: [{ type: 'number', minimum: 1, maximum: 120 }, { type: 'string', pattern: '^[0-9]+/[0-9]+$' }],
9206
- 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.',
9219
+ 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. The bitrate is a ceiling. A picture that needs it gets fewer bits per frame at a higher fps, at about the same size; one under it costs what its frames need, so its file grows with fps.',
9207
9220
  },
9208
9221
  scale: {
9209
9222
  type: 'number',