@lottiefiles/creator-mcp 0.2.0 → 0.2.2

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 (2) hide show
  1. package/dist/index.mjs +73 -102
  2. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -356,8 +356,8 @@ const maskEllipseInputSchema = z.object({
356
356
  width: z.number().min(0),
357
357
  height: z.number().min(0)
358
358
  }).strict();
359
- const maskModeInputSchema = z.enum(["add", "subtract"]).describe("Default 'add'.");
360
- const maskOpacityInputSchema = z.number().min(0).max(100).describe("Default 100.");
359
+ const maskModeInputSchema = z.enum(["add", "subtract"]).describe("Default 'add'. 'add' = show ONLY the content inside the mask outline (the normal clip/reveal). 'subtract' = cut a HOLE — hide the content inside the outline and show everything outside it.");
360
+ const maskOpacityInputSchema = z.number().min(0).max(100).describe("Mask opacity, not an enable/disable strength. Default 100. For an ADD mask, 100 shows the content inside the outline and 0 makes the masked layer fully transparent. For a SUBTRACT mask, 100 fully cuts the content inside the outline and 0 cuts nothing. Use `remove_mask` to restore unmasked rendering.");
361
361
  const createMaskInputSchema = z.object({
362
362
  layer_id: z.string(),
363
363
  shape: z.enum([
@@ -453,11 +453,13 @@ const easingInputSchema = z.union([z.object({ type: z.literal("LINEAR") }).stric
453
453
  y1: z.number(),
454
454
  x2: z.number(),
455
455
  y2: z.number()
456
- }).strict()]);
457
- const keyframeEasingInputSchema = z.union([easingInputSchema, z.object({
456
+ }).strict().describe("Explicit bezier control points [x1, y1, x2, y2]. **Only `apply_keyframes` and `retime_keyframes` accept the `{ type: 'PRESET', name }` shorthand; `animate_paint`, `animate_property` and `set_group_transform` need these raw coefficients — they silently downgrade an unrecognised easing shape to LINEAR (wrong motion, no error).** Preset equivalents: ease-in 0.42,0,1,1 · ease-out 0,0,0.58,1 · ease-in-out 0.42,0,0.58,1 · gentle-out 0.22,1,0.36,1 · snappy-out 0.16,1,0.3,1 · smooth-in-out 0.65,0,0.35,1 · overshoot-soft 0.34,1.56,0.64,1 · overshoot-hard 0.68,-0.55,0.265,1.55 · anticipate 0.22,-0.4,0.56,1.")]);
457
+ const presetEasingInputSchema = z.object({
458
458
  type: z.literal("PRESET"),
459
459
  name: easingPresetNameSchema
460
- }).strict().describe("Named curve — prefer this over hand-picked CUBIC_BEZIER coefficients. Presets expand to the exact bezier values server-side. Each maps to a motion PERSONALITY archetype — pick the one matching your Direction and use it consistently (see motion-design.md → Motion Personality):\n- 'gentle-out' — PREMIUM archetype (elegant/luxury/minimal): smooth ease-out, 0% overshoot, ~350-600ms feel. The reveal default; spreads motion across the full duration.\n- 'snappy-out' — CORPORATE archetype (clean/professional/dashboard): quick, decisive settle, 0-3% overshoot, ~200-400ms.\n- 'overshoot-soft' / 'overshoot-hard' — PLAYFUL archetype (fun/bouncy/cute): pop-in / bouncy settle, 10-20% overshoot, ~150-300ms.\n- 'anticipate' — PLAYFUL/ENERGETIC wind-up: pulls back before moving forward.\n- 'ease-in-out' / 'smooth-in-out' — both ends, cinematic (neutral).\n- 'ease-in' / 'ease-out' — CSS-standard, reliable neutral default.")]);
460
+ }).strict().describe("Named curve — prefer this over hand-picked CUBIC_BEZIER coefficients. Presets expand to the exact bezier values server-side. Each maps to a motion PERSONALITY archetype — pick the one matching your Direction and use it consistently (see motion-design.md → Motion Personality):\n- 'gentle-out' — PREMIUM archetype (elegant/luxury/minimal): smooth ease-out, 0% overshoot, ~350-600ms feel. The reveal default; spreads motion across the full duration.\n- 'snappy-out' — CORPORATE archetype (clean/professional/dashboard): quick, decisive settle, 0-3% overshoot, ~200-400ms.\n- 'overshoot-soft' / 'overshoot-hard' — PLAYFUL archetype (fun/bouncy/cute): pop-in / bouncy settle, 10-20% overshoot, ~150-300ms.\n- 'anticipate' — PLAYFUL/ENERGETIC wind-up: pulls back before moving forward.\n- 'ease-in-out' / 'smooth-in-out' — both ends, cinematic (neutral).\n- 'ease-in' / 'ease-out' — CSS-standard, reliable neutral default.");
461
+ const keyframeEasingInputSchema = z.union([easingInputSchema, presetEasingInputSchema]);
462
+ const retimeEasingInputSchema = z.union([easingPresetNameSchema, keyframeEasingInputSchema]);
461
463
  const coreAnimatedPropertyValues = [
462
464
  "position",
463
465
  "scale",
@@ -516,6 +518,21 @@ const layoutShapeSchemaFields = {
516
518
  outer_roundness: z.number().min(0).max(100).describe("Polygon/star layout item: rounds the outer corners (0 = sharp, 100 = fully rounded). Default 0.").optional(),
517
519
  stroke: shapeStrokeInputSchema.describe("Shape layout item stroke outline. Supported on created shape items.").optional()
518
520
  };
521
+ const textAlignInputSchema = z.enum([
522
+ "left",
523
+ "center",
524
+ "right",
525
+ "justify_left",
526
+ "justify_center",
527
+ "justify_right",
528
+ "justify_full"
529
+ ]).describe("Horizontal alignment. `left` / `center` / `right` are plain alignment — each line is placed at that edge and nothing is stretched; these are what you want for headlines, labels and single-line text. The `justify_*` values FULL-JUSTIFY every line (words are spread to fill the wrap width, so they only do anything on wrapped multi-line text — see `wrap_width`) and differ only in how the LAST line is treated: `justify_left` = last line flush left (the normal book/paragraph look), `justify_center` = last line centred, `justify_right` = last line flush right, `justify_full` = last line also stretched edge-to-edge. When omitted, the layer's existing alignment is preserved (left for new unwrapped text); `wrap_width` without an explicit `align` uses center alignment.");
530
+ const statePlayModeInputSchema = z.enum([
531
+ "Forward",
532
+ "Reverse",
533
+ "Bounce",
534
+ "ReverseBounce"
535
+ ]).describe("Playback direction for this state's segment. Default 'Forward'.\n- 'Forward' — plays first frame → last frame.\n- 'Reverse' — plays last frame → first frame.\n- 'Bounce' — PING-PONG: forward, then backward, then forward… This is the answer to 'make it ping-pong', 'make it go back and forth', 'yo-yo it', 'play it in reverse after it finishes'. Combine with `loop: true` for a continuous back-and-forth.\n- 'ReverseBounce' — the same ping-pong, but starting with the BACKWARD pass.");
519
536
  /**
520
537
  * Client-executed Motion Copilot V3 tools.
521
538
  *
@@ -529,13 +546,13 @@ const allClientTools = {
529
546
  inputSchema: z.object({}).strict()
530
547
  },
531
548
  apply_keyframes: {
532
- description: "Add keyframes to ONE animatable property on ONE layer. This is the preferred tool for simple single-property animations (rotate, fade, slide, pulse, recolor). If you need to animate multiple properties on the same layer, plan separate tool calls in the order they should be applied; Creator serializes native tool execution through the dispatcher.\n\n**POLISH TURNS (snappier / smoother / tighter / less bouncy / fix timing / more premium / etc.) — call `read_keyframes` FIRST on every animated layer in scope BEFORE calling this tool.** The conversation history's view of existing keyframes is stale once the user edits in Creator directly; reading is the only safe source of truth. Then prefer the smallest possible mutation: easing-only change > retiming > amplitude tweak > full rebuild. See motion-design.md → 'Repair / polish protocol' for the full rule.\n\nValue type depends on the property:\n- `position`, `scale` → value is `{x, y}`. **`scale` is a percentage of the layer's AUTHORED size, matching AE / Creator semantics — NOT a percentage of 100.** A layer the user imported or hand-resized can sit at `50`, `200`, `1250`, etc. — and THAT value is the resting state. Animating a layer authored at 1250 → 110 collapses it to ~9% of its on-canvas size and looks like a bug. **Always read the current static value via `read_keyframes` or `read_scene` before writing scale keyframes**, then animate RELATIVE to that authored value (1250 pulses to 1350, not to 110). Same baseline rule applies to `position` and `rotation` — the layer's authored values are the resting state, not 0/0 or 0°. See motion-design.md → 'Baseline awareness'. **TEXT `position` is doubly dangerous: a text layer's transform anchor is the baseline / box origin, so its raw `position` value is NOT its `visual_center` and NOT the `position` arg you gave `create_text` (that arg is the visual center). Feeding `visual_center` in as a `position` keyframe throws the text off-canvas. For a settle-into-place entrance use `animate_entrance` (no coordinate needed); for any other text move, `read_keyframes` the raw `static_value` and animate RELATIVE to it. Never repair a keyframed text layer with `set_anchor_point` → `center_layer` — they stack two shifts and fling it off-canvas.**\n- `rotation`, `opacity` → value is a number (rotation in degrees; opacity 0–100)\n- `fill_color` → value is `{r, g, b}` with 0-255 per channel. **Requires a SOLID fill at `fills[0]`** — animation only works when the layer's first fill is SOLID. Gradient or empty layers will fail with `'Layer has no SOLID fill at fills[0]'`. To animate the color of a layer that currently has a gradient (or no fill), call `set_fill({type:'SOLID', color: <starting color>})` FIRST, then this tool. Verify the fill type via `read_scene` (look at the layer's fill metadata) or just call `set_fill` defensively before animating colors on a layer you didn't create with a solid fill.\n- `trim_start`, `trim_end` → number 0–100 (percent of path). Use for stroke draw-on reveals: animate `trim_end` from 0 → 100 to grow the path in. A trim path is auto-created on first use. If the current playhead stays at a frame where `trim_end` is 0 or opacity is 0, the layer is correctly present but invisible on canvas; after creating a reveal, call `set_current_frame` to leave Creator on a visible frame.\n- `trim_offset` → number 0–360 (degrees around the path). For rotating-around-path effects.\n- `stroke_width` → number (pixels). Requires the layer to already have a stroke — use `set_stroke` first.\n\nTo set a static color (no animation), pass a single keyframe at frame 0 and consider `clear_first: true` to remove any existing color animation.\n\nEasing is optional on any keyframe; omit it for linear-ish default. Use `CUBIC_BEZIER` with explicit control points for polished motion; `LINEAR` for deliberate no-easing.\n\nUse `clear_first: true` to replace existing keyframes on that property; otherwise new keyframes are merged with existing ones (sorted by frame).\n\nFor paint, shape geometry, masks, groups, scene settings, and timing edits, prefer the dedicated typed tools (`animate_paint`, `animate_property`, `create_shape_group`, `update_mask`, `retime_keyframes`) over rebuilding with many generic keyframe calls.",
549
+ description: "Add keyframes to ONE animatable property on ONE layer. This is the preferred tool for simple single-property animations (rotate, fade, slide, pulse, recolor). If you need to animate multiple properties on the same layer, plan separate tool calls in the order they should be applied; Creator serializes native tool execution through the dispatcher.\n\n**POLISH TURNS (snappier / smoother / tighter / less bouncy / fix timing / more premium / etc.) — call `read_keyframes` FIRST on every animated layer in scope BEFORE calling this tool.** The conversation history's view of existing keyframes is stale once the user edits in Creator directly; reading is the only safe source of truth. Then prefer the smallest possible mutation: easing-only change > retiming > amplitude tweak > full rebuild. See motion-design.md → 'Repair / polish protocol' for the full rule.\n\nValue type depends on the property:\n- `position`, `scale` → value is `{x, y}`. **`scale` is a percentage of the layer's AUTHORED size, matching AE / Creator semantics — NOT a percentage of 100.** A layer the user imported or hand-resized can sit at `50`, `200`, `1250`, etc. — and THAT value is the resting state. Animating a layer authored at 1250 → 110 collapses it to ~9% of its on-canvas size and looks like a bug. **Always read the current static value via `read_keyframes` or `read_scene` before writing scale keyframes**, then animate RELATIVE to that authored value (1250 pulses to 1350, not to 110). Same baseline rule applies to `position` and `rotation` — the layer's authored values are the resting state, not 0/0 or 0°. See motion-design.md → 'Baseline awareness'. **TEXT `position` is doubly dangerous: a text layer's transform anchor is the baseline / box origin, so its raw `position` value is NOT its `visual_center` and NOT the `position` arg you gave `create_text` (that arg is the visual center). Feeding `visual_center` in as a `position` keyframe throws the text off-canvas. For a settle-into-place entrance use `animate_entrance` (no coordinate needed); for any other text move, `read_keyframes` the raw `static_value` and animate RELATIVE to it. Never repair a keyframed text layer with `set_anchor_point` → `center_layer` — they stack two shifts and fling it off-canvas.**\n- `rotation`, `opacity` → value is a number (rotation in degrees; opacity 0–100)\n- `fill_color` → value is `{r, g, b}` with 0-255 per channel. **Requires a SOLID fill at `fills[0]`** — animation only works when the layer's first fill is SOLID. Gradient or empty layers will fail with `'Layer has no SOLID fill at fills[0]'`. To animate the color of a layer that currently has a gradient (or no fill), call `set_fill({type:'SOLID', color: <starting color>})` FIRST, then this tool. Verify the fill type via `read_scene` (look at the layer's fill metadata) or just call `set_fill` defensively before animating colors on a layer you didn't create with a solid fill.\n- `trim_start`, `trim_end` → number 0–100 (percent of path). Use for stroke draw-on reveals: animate `trim_end` from 0 → 100 to grow the path in. A trim path is auto-created on first use. If the current playhead stays at a frame where `trim_end` is 0 or opacity is 0, the layer is correctly present but invisible on canvas; after creating a reveal, call `set_current_frame` to leave Creator on a visible frame.\n- `trim_offset` → number 0–360 (degrees around the path). For rotating-around-path effects.\n- `stroke_width` → number (pixels). Requires the layer to already have a stroke — use `set_stroke` first.\n\nTo set a static color (no animation), pass a single keyframe at frame 0 and consider `clear_first: true` to remove any existing color animation.\n\nEasing is optional on any keyframe; omit it for linear-ish default. Use `CUBIC_BEZIER` with explicit control points for polished motion; `LINEAR` for deliberate no-easing.\n\nUse `clear_first: true` to replace existing keyframes on that property; otherwise new keyframes are merged with existing ones (sorted by frame).\n\nFor paint, shape geometry, masks, groups, scene settings, and timing edits, prefer the dedicated typed tools (`animate_paint`, `animate_property`, `create_shape_group`, `update_mask`, `retime_keyframes`) over rebuilding with many generic keyframe calls.\n\n**RESPONSE — verify before saying \"Done\":** returns `{ layer_id, property, added, total_after }`. `added` is how many keyframes THIS call submitted; **`total_after` is a real read-back of how many keyframes the property holds afterwards** — it is re-read from the layer, not echoed from your input.\n- **Write success:** the call did not throw and the requested value was read back. A non-throwing one-keyframe write with `total_after: 1` is a valid static write.\n- **Animation success:** motion requires at least two distinct stored keyframe frames. After a multi-keyframe submission, `total_after: 1` means the write collapsed and the layer will not animate; call `read_keyframes` if the returned count or frames are unexpected.\n- With `clear_first: true`, expect `total_after === added`. A LARGER number means keyframes you did not write survived on that property and the result is a blend of old and new — `read_keyframes` before reporting done.\n- Without `clear_first`, `total_after` is the merged count. If it is lower than (previous + `added`), some of your frames landed on frames that already had keyframes and replaced them rather than adding.\n- Throws (and writes NOTHING) on an unknown `layer_id`, on a property that is not animatable on that layer, and on an unknown easing PRESET `name`.",
533
550
  inputSchema: z.object({
534
551
  layer_id: z.string().describe("Layer id from `read_scene` (e.g. 'qttDVXo7OQ')."),
535
552
  property: coreAnimatedPropertySchema,
536
553
  clear_first: z.boolean().describe("If true, clear existing keyframes on this property before adding. Default false.").optional(),
537
554
  keyframes: z.array(z.object({
538
- frame: z.number(),
555
+ frame: z.number().int().min(0).describe("Whole frame number on the scene timeline. Must be an INTEGER >= 0 and <= the scene `duration_frames` from `read_scene` — Creator has no sub-frame keyframes, and a frame past the scene duration is written but never played. Round any computed frame (e.g. a third of a 25-frame beat is 8, not 8.333)."),
539
556
  value: z.union([
540
557
  z.number(),
541
558
  vector2Schema,
@@ -620,7 +637,7 @@ const allClientTools = {
620
637
  "action",
621
638
  "accent"
622
639
  ]).describe("Optional. Resolves a DEFAULT slate fill when `fill_color` is OMITTED. 'accent' = slate-700 NEUTRAL default (NOT a chromatic hue — pass `fill` for chroma). An explicit `fill_color` always wins; ignored when `fill_color` is given.").optional(),
623
- fill: z.string().describe("Optional named Flat-UI accent, e.g. 'peter-river','carrot','emerald','sun-flower','alizarin','belize-hole','pomegranate'. ONE chromatic hue; purple/violet never (unknown names are ignored). Wins over `role`; only applies when `fill_color` is omitted.").optional(),
640
+ fill: z.string().describe("Optional named Flat-UI accent, e.g. 'peter-river','carrot','emerald','sun-flower','alizarin','belize-hole','pomegranate'. ONE chromatic hue; purple/violet never (unknown names are ignored). Wins over `role`; only applies when `fill_color` is omitted. **Name collision: `fill` here is a palette NAME STRING — the `fill` on `set_fill` / `set_stroke` / `add_shape_to_layer` is a PAINT OBJECT (`{ type: 'SOLID', color: {r,g,b} }`). These schemas are strict, so a paint object passed here is rejected; for an explicit colour on this call use `fill_color`.**").optional(),
624
641
  path: z.object({
625
642
  points: z.array(z.object({
626
643
  vertex: z.object({
@@ -686,7 +703,7 @@ const allClientTools = {
686
703
  }).strict()
687
704
  },
688
705
  create_text: {
689
- description: "**BEFORE CALLING: check the creation order.** New text layers are inserted at the TOP of the render stack. If this turn also creates a card / background / banner, create the background FIRST and THIS text layer LAST — otherwise the text will land beneath the background. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nCreate a new native TEXT_LAYER with the given content and optional native TextDocument style. Returns `layer_id` so you can immediately animate it with `apply_keyframes` (opacity for fade-in, position for slide, scale for pop). For letter-by-letter or word-by-word staggered animations, emit one text-layer call per unit in the order they should be created; Creator serializes native tool execution.\n\n**Centering is by VISUAL BOUNDS, not layer origin.** Use `center_in_scene: true` for scene center. Use `position: {x,y}` when you want the bounds center at a specific target point (e.g. top-center hero text). Do not manually compensate for text origin/anchor.\n\n**For long body copy that needs to wrap, pass `wrap_width` instead of constructing a text box yourself.** The gateway translates `wrap_width` into a centered wrap box internally — your `position` / `center_in_scene` still controls visual placement. Omit `wrap_width` for headlines, subtitles, footers, single-line text.\n\nNative text styling supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width`. For \"slight tracking\", use a small value such as 2-8; values around 20-40 are very wide and can make title text exceed the scene.\n\n**RESPONSE — verify these BEFORE saying \"Done\":**\n- `visual_center` is where the rendered text actually centers. Compare against your intent (`position` or scene center).\n- `visual_bounds` is the rendered rectangle in world coords.\n- `scene_fit.clipped` / `scene_fit.off_canvas` / `scene_fit.overflow.{left,right,top,bottom}`: if any overflow > 0, the new text is clipped at the canvas edge. Self-correct (reduce `font_size`, add `wrap_width`, shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
706
+ description: "**BEFORE CALLING: check the creation order.** New text layers are inserted at the TOP of the render stack. If this turn also creates a card / background / banner, create the background FIRST and THIS text layer LAST — otherwise the text will land beneath the background. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nCreate a new native TEXT_LAYER with the given content and optional native TextDocument style. Returns `layer_id` so you can immediately animate it with `apply_keyframes` (opacity for fade-in, position for slide, scale for pop). For letter-by-letter or word-by-word staggered animations, emit one text-layer call per unit in the order they should be created; Creator serializes native tool execution.\n\n**Centering is by VISUAL BOUNDS, not layer origin.** Use `center_in_scene: true` for scene center. Use `position: {x,y}` when you want the bounds center at a specific target point (e.g. top-center hero text). Do not manually compensate for text origin/anchor.\n\n**For long body copy that needs to wrap, pass `wrap_width` instead of constructing a text box yourself.** The gateway translates `wrap_width` into a centered wrap box internally — your `position` / `center_in_scene` still controls visual placement. `wrap_width` without an explicit `align` uses center alignment; pass `align: \"left\"` for a conventional paragraph column. Omit `wrap_width` for headlines, subtitles, footers, single-line text.\n\nNative text styling supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width`. For \"slight tracking\", use a small value such as 2-8; values around 20-40 are very wide and can make title text exceed the scene.\n\n**RESPONSE — verify these BEFORE saying \"Done\":**\n- `visual_center` is where the rendered text actually centers. Compare against your intent (`position` or scene center).\n- `visual_bounds` is the rendered rectangle in world coords.\n- `scene_fit.clipped` / `scene_fit.off_canvas` / `scene_fit.overflow.{left,right,top,bottom}`: if any overflow > 0, the new text is clipped at the canvas edge. Self-correct (reduce `font_size`, add `wrap_width`, shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
690
707
  inputSchema: z.object({
691
708
  text: z.string().min(1).describe("The text content to display."),
692
709
  name: z.string().describe("Layer name (defaults to the first ~24 chars of `text`).").optional(),
@@ -695,25 +712,17 @@ const allClientTools = {
695
712
  y: z.number()
696
713
  }).strict().describe("Visual center target for the text bounds. Ignored if center_in_scene is true.").optional(),
697
714
  center_in_scene: z.boolean().describe("If true, place the rendered text bounding box at scene center using rendered bounds when available and measured text bounds as fallback. Use whenever the user asks for centered text; overrides `position`.").optional(),
698
- rotation: z.number().optional(),
715
+ rotation: z.number().describe("Degrees, default 0.").optional(),
699
716
  scale: z.object({
700
717
  x: z.number(),
701
718
  y: z.number()
702
- }).strict().optional(),
719
+ }).strict().describe("PERCENTAGE, default `{ x: 100, y: 100 }` — matching AE / Creator semantics, NOT a 0–1 multiplier. `{ x: 1, y: 1 }` renders the text at ONE PERCENT of its size (an invisible speck), which is the classic mistake here. Use `font_size` to set type size; only use `scale` when you specifically want a transform-level scale.").optional(),
703
720
  opacity: z.number().min(0).max(100).optional(),
704
721
  font_size: z.number().gt(0).optional(),
705
722
  font_name: z.string().describe("Direct Creator font asset name, e.g. \"Inter Bold\". Prefer font_family/font_variant when known.").optional(),
706
723
  font_family: z.string().describe("Font family, e.g. Inter.").optional(),
707
724
  font_variant: z.string().describe("Font variant/style, e.g. Bold.").optional(),
708
- align: z.enum([
709
- "left",
710
- "center",
711
- "right",
712
- "justify_left",
713
- "justify_center",
714
- "justify_right",
715
- "justify_full"
716
- ]).optional(),
725
+ align: textAlignInputSchema.optional(),
717
726
  line_height: z.number().gt(0).optional(),
718
727
  tracking: z.number().describe("Native text tracking / letter spacing value. For \"slight tracking\", use 2-8. Avoid 20+ unless the user explicitly asks for very wide spacing.").optional(),
719
728
  text_caps: z.enum([
@@ -731,7 +740,7 @@ const allClientTools = {
731
740
  "caption",
732
741
  "accent"
733
742
  ]).describe("Optional. Resolves a DEFAULT slate text fill when `fill_color` is omitted: heading=slate-900, body=slate-600, caption=slate-500. Sets COLOR ONLY (not size/line-height — use `font_size` / create_text_group for typography). 'accent' = slate-700 neutral; pass `fill` for chroma. Explicit `fill_color` always wins.").optional(),
734
- fill: z.string().describe("Optional named Flat-UI accent (kebab, e.g. 'peter-river'). Wins over `role`; only applies when `fill_color` is omitted; purple/unknown ignored.").optional()
743
+ fill: z.string().describe("Optional named Flat-UI accent (kebab, e.g. 'peter-river'). Wins over `role`; only applies when `fill_color` is omitted; purple/unknown ignored. **Name collision: `fill` here is a palette NAME STRING — the `fill` on `set_fill` / `set_stroke` is a PAINT OBJECT. For an explicit colour on this call use `fill_color`.**").optional()
735
744
  }).strict()
736
745
  },
737
746
  create_text_group: {
@@ -891,22 +900,14 @@ const allClientTools = {
891
900
  }).strict()
892
901
  },
893
902
  set_text_style: {
894
- description: "Update native TextDocument styling on an existing TEXT_LAYER. Supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width` (for paragraph copy that needs to wrap inside a fixed-width column). Pass `fill_color: null` to clear the fill override, `stroke_color: null` to clear the stroke color override, and `stroke_width: null` to clear the stroke width override. By default it preserves the current visual bounds center after style changes. For \"slight tracking\", use 2-8, not 20-40.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. Bumping `font_size` can push the rendered text past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text_style call. If anything overflows, self-correct (smaller multiplier, add `wrap_width` to wrap, or bump the scene via `set_scene_settings`) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
903
+ description: "Update native TextDocument styling on an existing TEXT_LAYER. Supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width` (for paragraph copy that needs to wrap inside a fixed-width column). Setting `wrap_width` without an explicit `align` uses center alignment; pass `align: \"left\"` to keep a conventional paragraph column. Pass `fill_color: null` to clear the fill override, `stroke_color: null` to clear the stroke color override, and `stroke_width: null` to clear the stroke width override. By default it preserves the current visual bounds center after style changes. For \"slight tracking\", use 2-8, not 20-40.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. Bumping `font_size` can push the rendered text past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text_style call. If anything overflows, self-correct (smaller multiplier, add `wrap_width` to wrap, or bump the scene via `set_scene_settings`) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
895
904
  inputSchema: z.object({
896
905
  layer_id: z.string(),
897
906
  font_size: z.number().gt(0).optional(),
898
907
  font_name: z.string().describe("Direct Creator font asset name, e.g. \"Inter Bold\". Prefer font_family/font_variant when known.").optional(),
899
908
  font_family: z.string().optional(),
900
909
  font_variant: z.string().optional(),
901
- align: z.enum([
902
- "left",
903
- "center",
904
- "right",
905
- "justify_left",
906
- "justify_center",
907
- "justify_right",
908
- "justify_full"
909
- ]).optional(),
910
+ align: textAlignInputSchema.optional(),
910
911
  line_height: z.number().gt(0).optional(),
911
912
  tracking: z.number().describe("Native text tracking / letter spacing value. For \"slight tracking\", use 2-8. Avoid 20+ unless the user explicitly asks for very wide spacing.").optional(),
912
913
  text_caps: z.enum([
@@ -987,17 +988,7 @@ const allClientTools = {
987
988
  per_item_delay_frames: z.number().min(0).describe("Frames between each layer's start. If the user gives a literal number (\"10-frame stagger\") use it verbatim. Default 4.").optional(),
988
989
  start_frame: z.number().min(0).describe("Frame the first layer starts. Default 0.").optional(),
989
990
  duration_frames: z.number().min(1).describe("Each layer's entrance duration. Default 16.").optional(),
990
- easing: z.enum([
991
- "ease-in",
992
- "ease-out",
993
- "ease-in-out",
994
- "gentle-out",
995
- "snappy-out",
996
- "smooth-in-out",
997
- "overshoot-soft",
998
- "overshoot-hard",
999
- "anticipate"
1000
- ]).describe("Easing preset name. Default 'gentle-out'.").optional(),
991
+ easing: easingPresetNameSchema.describe("Easing preset NAME as a bare string, e.g. 'gentle-out' — NOT the `{ type: 'PRESET', name }` object that `apply_keyframes` takes, and no LINEAR / CUBIC_BEZIER here. Default 'gentle-out'. To re-ease afterwards use `retime_keyframes({ operation: 'set_easing' })`, which accepts either shape.").optional(),
1001
992
  distance: z.number().min(0).describe("Slide travel in px (preset 'slide' only). Default 40.").optional(),
1002
993
  direction: z.enum([
1003
994
  "up",
@@ -1027,17 +1018,7 @@ const allClientTools = {
1027
1018
  distance: z.number().min(0).describe("Slide travel in px for rise/rise-fade. Default 40.").optional(),
1028
1019
  frames: z.number().min(1).describe("Entrance duration in frames. Default 16.").optional(),
1029
1020
  start_frame: z.number().min(0).describe("Frame the entrance starts (holds offset/invisible before). Default 0.").optional(),
1030
- easing: z.enum([
1031
- "ease-in",
1032
- "ease-out",
1033
- "ease-in-out",
1034
- "gentle-out",
1035
- "snappy-out",
1036
- "smooth-in-out",
1037
- "overshoot-soft",
1038
- "overshoot-hard",
1039
- "anticipate"
1040
- ]).describe("Easing preset name. Default 'gentle-out'.").optional()
1021
+ easing: easingPresetNameSchema.describe("Easing preset NAME as a bare string, e.g. 'gentle-out' — NOT the `{ type: 'PRESET', name }` object that `apply_keyframes` takes. Default 'gentle-out'. To re-ease afterwards use `retime_keyframes({ operation: 'set_easing' })`, which accepts either shape.").optional()
1041
1022
  }).strict()
1042
1023
  },
1043
1024
  list_fonts: {
@@ -1065,7 +1046,7 @@ const allClientTools = {
1065
1046
  inputSchema: z.object({
1066
1047
  layer_id: z.string(),
1067
1048
  replace_first: z.boolean().describe("If true (default), remove fills[0] before adding. If false, append.").optional(),
1068
- fill: paintInputSchema
1049
+ fill: paintInputSchema.describe("The paint to apply, as an OBJECT: `{ type: 'SOLID', color: {r,g,b} }`, `{ type: 'GRADIENT_LINEAR', stops: [...] }`, or `{ type: 'GRADIENT_RADIAL', stops: [...] }`. **Not the same `fill` as `create_shape` / `create_text`, where `fill` is a palette NAME STRING and a plain colour goes in `fill_color`.** This schema is strict — a bare string or a `{r,g,b}` without a `type` is rejected.")
1069
1050
  }).strict()
1070
1051
  },
1071
1052
  set_stroke: {
@@ -1074,7 +1055,7 @@ const allClientTools = {
1074
1055
  layer_id: z.string(),
1075
1056
  width: z.number().min(.1).describe("Stroke width in pixels."),
1076
1057
  replace_first: z.boolean().describe("If true (default), replace strokes[0]; else append.").optional(),
1077
- fill: paintInputSchema
1058
+ fill: paintInputSchema.describe("The stroke's paint, as an OBJECT — `{ type: 'SOLID', color: {r,g,b} }` or a gradient, same shape as `set_fill.fill`. Named `fill` because a stroke is painted with a fill; it is NOT the palette NAME STRING that `create_shape.fill` / `create_text.fill` take.")
1078
1059
  }).strict()
1079
1060
  },
1080
1061
  animate_paint: {
@@ -1101,13 +1082,13 @@ const allClientTools = {
1101
1082
  }).strict()
1102
1083
  },
1103
1084
  animate_property: {
1104
- description: "Keyframe extended properties not covered by apply_keyframes, including paint opacity/color, gradient handles, shape geometry, mask path/opacity, text animator colors, and scene-layer time mapping. Use this when the property is explicitly named and a typed high-level tool is not a better fit.",
1085
+ description: "Keyframe extended properties not covered by apply_keyframes, including paint opacity/color, gradient handles, shape geometry, mask path/opacity, text animator colors, and scene-layer time mapping. Use this when the property is explicitly named and a typed high-level tool is not a better fit.\n\n**Which sub-object gets animated** — the `property` picks a list on the layer, and `target_id`/`target_index` pick one entry in that list. **Both are optional and default to the FIRST entry**, which is what you want on the common single-mask / single-fill / single-shape layer:\n- `mask_path`, `mask_opacity` → the layer's masks (id from `create_mask`).\n- `shape_path`, `shape_size`, `rectangle_roundness`, `outer_radius`, `inner_radius`, `outer_roundness`, `inner_roundness`, `polygon_points`, `star_points` → the layer's shapes (id from `add_shape_to_layer`).\n- `stroke_color`, `stroke_opacity` → the layer's strokes; `fill_opacity`, `gradient_stops`, `gradient_start`, `gradient_end`, `highlight_angle`, `highlight_length` → the layer's fills, falling back to strokes when there is no matching fill.\n- `text_fill_color`, `text_stroke_color`, `text_stroke_width` → the layer's text animators (falls back to the layer itself when it has none).\n- `time_mapping` → the layer itself; both targeting fields are ignored.\n\n**RESPONSE:** returns `{ layer_id, property, target_index, target_id, added, total_after }`. `total_after` is a read-back of the keyframe count on the resolved property — treat `total_after >= 2` as the success predicate for an animation. Throws when the property is not available on the resolved target (e.g. `mask_path` on a layer with no masks).",
1105
1086
  inputSchema: z.object({
1106
1087
  layer_id: z.string(),
1107
1088
  property: extendedOnlyAnimatedPropertySchema,
1108
- target_index: z.number().min(0).optional(),
1109
- target_id: z.string().optional(),
1110
- clear_first: z.boolean().optional(),
1089
+ target_index: z.number().min(0).describe("Zero-based index into the list the `property` selects (masks / shapes / fills / strokes / text animators). **Defaults to 0 — the first entry.** Ignored when `target_id` is given. Use this only when the layer genuinely has several of that thing.").optional(),
1090
+ target_id: z.string().describe("Exact id of the sub-object to animate — a `mask_id` from `create_mask`, a `shape_id` from `add_shape_to_layer`, or a fill/stroke id. Wins over `target_index`. Omit it (and `target_index`) to animate the FIRST entry, which is the normal path since `read_scene` does not list mask / shape / paint ids.").optional(),
1091
+ clear_first: z.boolean().describe("Clear existing keyframes on the resolved property first. **Defaults to TRUE here** (unlike `apply_keyframes`, which defaults to false). Pass false to merge.").optional(),
1111
1092
  keyframes: z.array(z.object({
1112
1093
  frame: z.number(),
1113
1094
  value: extendedPropertyKeyframeValueSchema,
@@ -1470,12 +1451,7 @@ const allClientTools = {
1470
1451
  autoplay: z.boolean().describe("Start playing as soon as this state is entered. Default true for most states.").optional(),
1471
1452
  loop: z.boolean().describe("Loop the segment indefinitely. Default false.").optional(),
1472
1453
  loop_count: z.number().min(1).describe("Number of times to loop. Ignored unless loop is true.").optional(),
1473
- mode: z.enum([
1474
- "Forward",
1475
- "Reverse",
1476
- "Bounce",
1477
- "ReverseBounce"
1478
- ]).describe("Playback direction. Default Forward.").optional(),
1454
+ mode: statePlayModeInputSchema.optional(),
1479
1455
  speed: z.number().describe("Playback speed multiplier (e.g. 1.0, 0.5, 2.0). Default 1.").optional(),
1480
1456
  must_complete: z.boolean().describe("If true, the segment must finish before any transition can fire. Default false.").optional()
1481
1457
  }).strict()
@@ -1494,17 +1470,12 @@ const allClientTools = {
1494
1470
  state_id: z.string().describe("State id from `list_states`."),
1495
1471
  name: z.string().optional(),
1496
1472
  segment: z.string().describe("New segment name. Use empty string to clear.").optional(),
1497
- autoplay: z.boolean().optional(),
1498
- loop: z.boolean().optional(),
1499
- loop_count: z.number().min(1).optional(),
1500
- mode: z.enum([
1501
- "Forward",
1502
- "Reverse",
1503
- "Bounce",
1504
- "ReverseBounce"
1505
- ]).optional(),
1506
- speed: z.number().optional(),
1507
- must_complete: z.boolean().optional()
1473
+ autoplay: z.boolean().describe("Start playing as soon as this state is entered.").optional(),
1474
+ loop: z.boolean().describe("Loop the segment indefinitely.").optional(),
1475
+ loop_count: z.number().min(1).describe("Number of times to loop. Ignored unless `loop` is true.").optional(),
1476
+ mode: statePlayModeInputSchema.optional(),
1477
+ speed: z.number().describe("Playback speed multiplier (e.g. 1.0, 0.5, 2.0).").optional(),
1478
+ must_complete: z.boolean().describe("If true, the segment must finish before any transition can fire.").optional()
1508
1479
  }).strict()
1509
1480
  },
1510
1481
  set_state_actions: {
@@ -1893,7 +1864,7 @@ const allClientTools = {
1893
1864
  }).strict()
1894
1865
  },
1895
1866
  vectorize_image: {
1896
- description: "Convert an existing PNG/JPG image layer into editable vector shape layers using Creator native vectorizer. Use this when the user asks to vectorize, trace, convert an image/logo/photo to vectors, or make an imported raster image editable. Defaults to the selected image layer when `layer_id` is omitted. The generated vector layers are inserted above the source image and selected.",
1867
+ description: "Convert an existing PNG/JPG image layer into editable vector shape layers using Creator's vectorizer. Use this when the user asks to vectorize, trace, convert an image/logo/photo to vectors, or make an imported raster image editable. Defaults to the selected image layer when `layer_id` is omitted. The generated vector layers are inserted above the source and selected; after at least one vector layer is created, the original raster is preserved but hidden so it does not render or export twice. Restore it with `set_layer_visibility({ layer_id: source_layer_id, visible: true })` when the user wants to compare or revert.",
1897
1868
  inputSchema: z.object({ layer_id: z.string().describe("Image layer id from `read_scene`. Optional when an image layer is selected.").optional() }).strict()
1898
1869
  },
1899
1870
  remove_keyframes: {
@@ -1960,16 +1931,16 @@ const allClientTools = {
1960
1931
  }).strict()
1961
1932
  },
1962
1933
  create_shape_group: {
1963
- description: "Create an empty group inside an existing SHAPE_LAYER or group. Use this to build composed single-layer components like cards, icons, badges, and shadows without creating many top-level layers.",
1934
+ description: "Create an empty group inside an existing SHAPE_LAYER or group. Use this to build composed single-layer components like cards, icons, badges, and shadows without creating many top-level layers.\n\n**RESPONSE — keep `group_id`, it is the only way to address the group afterwards:** returns `{ group_id, parent_id, name }`. `read_scene` returns LAYERS, not the shape/group tree inside them, so this response is your only source of the id. Feed `group_id` straight into `add_shape_to_layer({ parent_id: group_id })` to fill the group and into `set_group_transform({ group_id })` to move / fade the whole group as one unit.",
1964
1935
  inputSchema: z.object({
1965
- parent_id: z.string(),
1966
- name: z.string().optional()
1936
+ parent_id: z.string().describe("Container to create the group inside: a SHAPE_LAYER id (the `layer_id` returned by `create_shape`) or another group id (a `group_id` returned by an earlier `create_shape_group`). Non-shape layers are rejected."),
1937
+ name: z.string().describe("Optional group name, e.g. \"card-shadow\". Defaults to Creator's.").optional()
1967
1938
  }).strict()
1968
1939
  },
1969
1940
  add_shape_to_layer: {
1970
- description: "Add a rectangle, ellipse, polygon, star, or path to an existing SHAPE_LAYER or group. Use with create_shape_group to build one composed unit with internal groups.",
1941
+ description: "Add a rectangle, ellipse, polygon, star, or path to an existing SHAPE_LAYER or group. Use with create_shape_group to build one composed unit with internal groups.\n\n**RESPONSE:** returns `{ parent_id, shape_id, shape, fill_id, stroke_id }` (`fill_id` / `stroke_id` only when you passed `fill` / `stroke`). Keep `shape_id` — it is the `target_id` for `animate_property` on shape geometry (`shape_size`, `rectangle_roundness`, `shape_path`, …), and `read_scene` does not list the shapes inside a layer. If the fill or stroke fails to attach, the whole call is rolled back (the shape is removed) and it throws, so a successful response means every requested piece exists.",
1971
1942
  inputSchema: z.object({
1972
- parent_id: z.string(),
1943
+ parent_id: z.string().describe("Container to add the shape to: a SHAPE_LAYER id (from `create_shape`) or a `group_id` from `create_shape_group`."),
1973
1944
  shape: z.enum([
1974
1945
  "rectangle",
1975
1946
  "ellipse",
@@ -1988,23 +1959,23 @@ const allClientTools = {
1988
1959
  outer_roundness: z.number().min(0).max(100).optional(),
1989
1960
  inner_roundness: z.number().min(0).max(100).optional(),
1990
1961
  path: pathDataInputSchema.optional(),
1991
- fill: paintInputSchema.optional(),
1992
- stroke: paintInputWithOptionalWidthSchema.optional(),
1962
+ fill: paintInputSchema.describe("PAINT OBJECT — `{ type: 'SOLID', color: {r,g,b} }` or a gradient, same shape as `set_fill.fill`. NOT the palette NAME STRING that `create_shape.fill` takes.").optional(),
1963
+ stroke: paintInputWithOptionalWidthSchema.describe("PAINT OBJECT plus an optional `width` (px, default 1) — e.g. `{ type: 'SOLID', color: {r,g,b}, width: 2 }`. NOT the `{ color, width }` shape that `create_shape.stroke` takes.").optional(),
1993
1964
  name: z.string().optional()
1994
1965
  }).strict()
1995
1966
  },
1996
1967
  set_group_transform: {
1997
- description: "Set or keyframe a shape group transform/opacity property. Use this when a composed unit has internal groups that should move or fade together.",
1968
+ description: "Set or keyframe a shape group transform/opacity property. Use this when a composed unit has internal groups that should move or fade together.\n\nPass EITHER `value` (a static set) OR `keyframes` (an animation) — the call throws if you pass neither. `keyframes` wins when both are given.\n\nReturns `{ group_id, property, keyframes_added }`; `keyframes_added` is 0 for a static `value` set.",
1998
1969
  inputSchema: z.object({
1999
- group_id: z.string(),
1970
+ group_id: z.string().describe("Group id from the `create_shape_group` response. `read_scene` lists layers, not the groups inside them, so that response (or an earlier `add_shape_to_layer` parent) is where this id comes from."),
2000
1971
  property: z.enum([
2001
1972
  "position",
2002
1973
  "scale",
2003
1974
  "rotation",
2004
1975
  "opacity"
2005
1976
  ]),
2006
- value: z.union([z.number(), vector2Schema]).optional(),
2007
- clear_first: z.boolean().optional(),
1977
+ value: z.union([z.number(), vector2Schema]).describe("Static value to set (no animation). `{x, y}` for position/scale, a number for rotation (degrees) / opacity (0-100). Omit when passing `keyframes`.").optional(),
1978
+ clear_first: z.boolean().describe("Clear existing keyframes on this group property before writing. **Defaults to TRUE here** (unlike `apply_keyframes`, which defaults to false). Pass false to merge with what is already there.").optional(),
2008
1979
  keyframes: z.array(z.object({
2009
1980
  frame: z.number(),
2010
1981
  value: z.union([z.number(), vector2Schema]),
@@ -2035,7 +2006,7 @@ const allClientTools = {
2035
2006
  }).strict()
2036
2007
  },
2037
2008
  retime_keyframes: {
2038
- description: "Shift, scale, reverse, evenly distribute, or RE-EASE keyframes on one layer property WITHOUT rebuilding values. Use for timing/feel polish like \"shift this 10 frames later\", \"make the bounce twice as fast\", or \"make it springier\".\n\n**To change the EASING / feel (springy, snappy, smoother, bouncy, more/less overshoot) use `operation: 'set_easing'` with `easing` — NOT apply_keyframes.** set_easing swaps only the easing curve and preserves every frame and value. Rewriting the animation via apply_keyframes forces you to restate all values and risks corrupting positions (e.g. collapsing a staggered word onto itself). For a multi-layer cascade (split-text letters, card grid), call set_easing once PER layer — each call is value-safe, so the layout cannot drift.",
2009
+ description: "Shift, scale, reverse, evenly distribute, or RE-EASE keyframes on one layer property WITHOUT rebuilding values. Use for timing/feel polish like \"shift this 10 frames later\", \"make the bounce twice as fast\", or \"make it springier\".\n\n**To change the EASING / feel (springy, snappy, smoother, bouncy, more/less overshoot) use `operation: 'set_easing'` with `easing` — NOT apply_keyframes.** set_easing swaps only the easing curve and preserves every frame and value. Rewriting the animation via apply_keyframes forces you to restate all values and risks corrupting positions (e.g. collapsing a staggered word onto itself). For a multi-layer cascade (split-text letters, card grid), call set_easing once PER layer — each call is value-safe, so the layout cannot drift.\n\n**Every operation reads the property's CURRENT keyframes, rewrites their frames, and puts the same values back — values are never touched by any operation.** The tool throws if the property has no keyframes yet (use `apply_keyframes` to create them first).\n\n`operation`, and the parameters each one reads (all others are ignored):\n- **`shift_frames`** — moves every keyframe by `offset` frames. Positive = LATER, negative = EARLIER. \"shift this 10 frames later\" = `{ operation: 'shift_frames', offset: 10 }`. **`offset` defaults to 0, so `{ operation: 'shift_frames' }` with no `offset` is schema-valid and does nothing** — always pass it. Frames are clamped at 0, so shifting earlier past the start piles those keyframes onto frame 0 and flattens that part of the motion; check the returned `frames`.\n- **`scale_time`** — multiplies each keyframe's DISTANCE FROM THE ORIGIN by `scale`: `new = origin + (old - origin) * scale`. The origin is `start_frame` if given, otherwise the property's FIRST keyframe. `scale` must be greater than 0; zero and negative values are rejected before any keyframes change. `scale < 1` = faster/shorter, `scale > 1` = slower/longer. \"make the bounce twice as fast\" = `{ operation: 'scale_time', scale: 0.5 }`; \"twice as slow\" = `scale: 2`. `scale` defaults to 1 (no-op) — always pass it. The result is NOT rounded, so a scale that does not divide the spans evenly leaves keyframes on fractional frames (frame 7 × 0.5 = 3.5); prefer factors that land on whole frames.\n- **`reverse`** — mirrors the frames inside a window: `new = start + end - old`, so the motion plays backwards while keeping the same span. The window defaults to the property's first→last keyframe; pass `start_frame`/`end_frame` to mirror inside a different window.\n- **`distribute_evenly`** — respaces the EXISTING keyframes at equal intervals between `start_frame` and `end_frame` (defaults: first→last keyframe), keeping their order and values. Use it to kill uneven timing (\"even out the spacing\", \"make the steps regular\"). It does not add or remove keyframes.\n- **`set_easing`** — replaces the easing curve on every keyframe of the property; frames and values are untouched. Requires `easing` (the call throws without it).\n\n**RESPONSE:** returns `{ layer_id, property, operation, retimed, frames }`. `retimed` is how many keyframes were rewritten (unchanged from before — this operation never adds or drops keyframes) and `frames` is the full new frame list, sorted. **Check `frames` before saying done:** repeated values mean keyframes collided (clamping at 0, or a `scale` small enough to merge them) and that stretch of the animation is now flat.",
2039
2010
  inputSchema: z.object({
2040
2011
  layer_id: z.string(),
2041
2012
  property: animatablePropertySchema,
@@ -2046,11 +2017,11 @@ const allClientTools = {
2046
2017
  "distribute_evenly",
2047
2018
  "set_easing"
2048
2019
  ]),
2049
- offset: z.number().optional(),
2050
- scale: z.number().optional(),
2051
- start_frame: z.number().optional(),
2052
- end_frame: z.number().optional(),
2053
- easing: easingPresetNameSchema.describe("Easing preset for operation 'set_easing'. 'overshoot-soft'/'overshoot-hard' = springy/bouncy; 'snappy-out' = snappy; 'smooth-in-out' = smooth. Applied to the property's keyframes in place, values untouched.").optional()
2020
+ offset: z.number().describe("Frames to move every keyframe by, for operation 'shift_frames'. POSITIVE = later, NEGATIVE = earlier. Defaults to 0 (a silent no-op), so always pass it for that operation. Ignored by every other operation.").optional(),
2021
+ scale: z.number().positive().describe("Positive time multiplier for operation 'scale_time', applied to each keyframe's distance from the origin (`start_frame`, else the first keyframe). Must be greater than 0; invalid values are rejected without changing keyframes. 0.5 = twice as fast, 2 = twice as slow. Defaults to 1 (a silent no-op), so always pass it for that operation. Ignored by every other operation.").optional(),
2022
+ start_frame: z.number().describe("Window start. For 'scale_time' this is the ORIGIN the scaling pivots around (keyframes at this frame do not move). For 'reverse' and 'distribute_evenly' it is the start of the frame window. Defaults to the property's FIRST existing keyframe. Ignored by 'shift_frames' and 'set_easing'.").optional(),
2023
+ end_frame: z.number().describe("Window end for 'reverse' (the mirror is `start + end - frame`) and 'distribute_evenly' (the last keyframe lands here). Defaults to the property's LAST existing keyframe. Ignored by 'shift_frames', 'scale_time' and 'set_easing'.").optional(),
2024
+ easing: retimeEasingInputSchema.describe("Easing for operation 'set_easing' (required by it, ignored by every other operation). 'overshoot-soft'/'overshoot-hard' = springy/bouncy; 'snappy-out' = snappy; 'smooth-in-out' = smooth. Applied to the property's keyframes in place, values and frames untouched.\n\nAccepts EITHER the bare preset name (`'gentle-out'`) OR the same object form `apply_keyframes` takes (`{ type: 'PRESET', name: 'gentle-out' }`, `{ type: 'LINEAR' }`, `{ type: 'CUBIC_BEZIER', x1, y1, x2, y2 }`) — carry whichever shape you already have, no translation needed.").optional()
2054
2025
  }).strict()
2055
2026
  },
2056
2027
  delete_layer: {
@@ -2058,21 +2029,21 @@ const allClientTools = {
2058
2029
  inputSchema: z.object({ layer_id: z.string() }).strict()
2059
2030
  },
2060
2031
  create_mask: {
2061
- description: "Add a mask to a layer. Masks clip the layer's visible content to an arbitrary shape — use for reveal wipes ('reveal from left'), clipping images/Lotties to a shape ('mask this image to a circle'), and any 'show only the part inside' request.\n\nFor the common shapes, pass one of the shorthands:\n\n- `shape: \"rectangle\"` + `rect: { x, y, width, height }` — axis-aligned rectangle in LAYER-local coordinates.\n- `shape: \"ellipse\"` + `ellipse: { cx, cy, width, height }` — center-defined ellipse, width/height being the bounding box. Plugin synthesizes a 4-point cubic bezier approximation (standard kappa constant).\n- `shape: \"path\"` + `path: { points: [{vertex:{x,y}, inTan:{x,y}, outTan:{x,y}}...], closed: boolean }` — raw PathData for complex shapes.\n\nCoords are LAYER-LOCAL — a rect at (0, 0, 100, 100) covers the top-left 100×100 of the layer's content, regardless of where the layer sits in the scene.\n\nOptional `mode` defaults to 'add' (show only inside the mask). Pass 'subtract' to cut a hole. Optional `opacity` (0–100) controls mask strength. Animate mask opacity/path with `animate_property({ layer_id, property: 'mask_opacity' | 'mask_path', target_id: mask_id, ... })`; `apply_keyframes` targets layer properties only.\n\nCommon recipes:\n- 'reveal from left': rectangle mask at (0, 0, 0, layerH), then animate width 0 → layerW with `animate_property` on `mask_path`.\n- 'circle clip': ellipse mask, cx/cy = layer center, width/height = desired diameter.\n- 'oval-vignette': ellipse mask slightly inset from layer bounds, opacity 0 → 100 animated with `animate_property` on `mask_opacity`.",
2032
+ description: "Add a mask to a layer. Masks clip the layer's visible content to an arbitrary shape — use for reveal wipes ('reveal from left'), clipping images/Lotties to a shape ('mask this image to a circle'), and any 'show only the part inside' request.\n\nFor the common shapes, pass one of the shorthands:\n\n- `shape: \"rectangle\"` + `rect: { x, y, width, height }` — axis-aligned rectangle in LAYER-local coordinates.\n- `shape: \"ellipse\"` + `ellipse: { cx, cy, width, height }` — center-defined ellipse, width/height being the bounding box. Plugin synthesizes a 4-point cubic bezier approximation (standard kappa constant).\n- `shape: \"path\"` + `path: { points: [{vertex:{x,y}, inTan:{x,y}, outTan:{x,y}}...], closed: boolean }` — raw PathData for complex shapes.\n\nCoords are LAYER-LOCAL — a rect at (0, 0, 100, 100) covers the top-left 100×100 of the layer's content, regardless of where the layer sits in the scene.\n\nOptional `mode` defaults to 'add' (show only inside the mask). Pass 'subtract' to cut a hole. Optional `opacity` (0–100) controls mask opacity; it is not an enable/disable strength. For an ADD mask, opacity 0 makes the masked layer fully transparent and 100 shows the content inside the outline. For a SUBTRACT mask, opacity 0 cuts nothing and 100 fully cuts inside the outline. Use `remove_mask` to restore unmasked rendering.\n\n**RESPONSE — this is the ONLY source of a mask id:** returns `{ layer_id, mask_id, shape, mode, opacity }`. `read_scene` does NOT list masks or mask ids, so if you need to address a specific mask later, keep `mask_id` from this response. Everything that takes a mask id also has a first-mask fallback, so a lost id is recoverable on single-mask layers: `update_mask` / `remove_mask` act on the FIRST mask when `mask_id` is omitted, and `animate_property` targets the first mask when both `target_id` and `target_index` are omitted.\n\nAnimate mask opacity/path with `animate_property({ layer_id, property: 'mask_opacity' | 'mask_path', ... })` — pass `target_id: mask_id` (or `target_index`) only when the layer has MORE THAN ONE mask; otherwise omit both and it uses the first one. `apply_keyframes` targets layer properties only and cannot reach masks. Note `mask_path` keyframe values are whole PathData objects (`{ points: [...], closed }`), not a width — build the full path for each keyframe.\n\nCommon recipes:\n- 'reveal from left': rectangle mask at (0, 0, 0, layerH), then animate `mask_path` from a zero-width rect to a full-width one with `animate_property`.\n- 'circle clip': ellipse mask, cx/cy = layer center, width/height = desired diameter.\n- 'oval-vignette': ellipse mask slightly inset from layer bounds, opacity 0 → 100 animated with `animate_property` on `mask_opacity`.",
2062
2033
  inputSchema: createMaskInputSchema
2063
2034
  },
2064
2035
  update_mask: {
2065
- description: "Update an existing mask path, opacity, or mode. Use after create_mask when the reveal shape needs tuning without recreating the mask.",
2036
+ description: "Update an existing mask path, opacity, or mode. Use after create_mask when the reveal shape needs tuning without recreating the mask. Only the fields you pass change; omitted fields are left alone.\n\nReturns `{ layer_id, mask_id, updated: { path, opacity, mode } }` — each `updated` flag reports whether that field was part of this call.",
2066
2037
  inputSchema: z.object({
2067
- layer_id: z.string(),
2068
- mask_id: z.string().optional(),
2069
- path: pathDataInputSchema.optional(),
2070
- opacity: z.number().min(0).max(100).optional(),
2071
- mode: z.enum(["add", "subtract"]).optional()
2038
+ layer_id: z.string().describe("Layer that owns the mask."),
2039
+ mask_id: z.string().describe("Mask id from the `create_mask` response. Omit to update the layer's FIRST mask — `read_scene` does not list mask ids, so on a single-mask layer omitting this is the normal path. Errors with \"mask not found\" if the id is not on this layer.").optional(),
2040
+ path: pathDataInputSchema.describe("Replacement mask outline, in LAYER-LOCAL coordinates.").optional(),
2041
+ opacity: z.number().min(0).max(100).describe("Mask opacity. For an ADD mask, 100 shows inside the outline and 0 makes the masked layer fully transparent. For a SUBTRACT mask, 100 fully cuts inside and 0 cuts nothing. Use `remove_mask` to restore unmasked rendering. Omit to leave unchanged.").optional(),
2042
+ mode: z.enum(["add", "subtract"]).describe("'add' = show ONLY the content inside the mask outline (the normal clip/reveal). 'subtract' = cut a HOLE — hide the content inside the outline and show everything outside it. Omit to leave the current mode unchanged.").optional()
2072
2043
  }).strict()
2073
2044
  },
2074
2045
  remove_mask: {
2075
- description: "Remove one mask from a layer. Pass mask_id when known; otherwise removes the first mask only.",
2046
+ description: "Remove one mask from a layer. Pass mask_id when known; otherwise removes the first mask only. Returns `{ layer_id, mask_id, removed: true }`.",
2076
2047
  inputSchema: z.object({
2077
2048
  layer_id: z.string(),
2078
2049
  mask_id: z.string().optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lottiefiles/creator-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "MCP server for LottieFiles Creator",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "tsx": "4.21.0",
32
32
  "typescript": "5.8.3",
33
33
  "typescript-eslint": "8.29.0",
34
- "vitest": "4.1.9",
35
- "@lottiefiles/motion-copilot-contract": "0.2.0",
36
- "@lottiefiles/creator-mcp-shared": "0.1.0"
34
+ "vitest": "4.1.10",
35
+ "@lottiefiles/creator-mcp-shared": "0.1.0",
36
+ "@lottiefiles/motion-copilot-contract": "0.2.1"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsdown",