From 90cef4f251393a8b9fe3c96188fa2c6ae36c74c3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 26 Aug 2026 20:59:42 -0400 Subject: [PATCH] feat(studio): let an agent author motion Four tools: add an animation, change its duration/ease/position, add a keyframe, delete it. This is the capability that makes the tool set worth having, because motion is the one thing an agent cannot judge or author from source. These are deliberately less confident than the rest of the set, and the reason is the handlers underneath them: `handleGsapAddAnimation(method)` takes only a method. Its insert position comes from the live playhead, not the caller, and the call is `void ...catch()` so it returns nothing. `handleGsapAddKeyframeBatch` returns a promise but catches its own failure, so awaiting proves the call finished, not that it landed. `handleGsapDeleteAnimation` discards its promise entirely. `handleGsapUpdateMeta` is the one honest signal. It returns a boolean. U8 handled the same problem by reading the result back. That does not work here: the animation list comes from React state that only refreshes on a render, and no render happens inside one tool call. Rather than fake a verification with a frame-timer, these report what was DISPATCHED and the descriptions tell the agent to call studio_inspect to see the result. Saying "I asked for this" is honest; saying "this happened" would not be. Three consequences worth stating: `studio_add_animation` takes no position. The handler reads the playhead, so accepting one would report a number that had no effect. It reports where the playhead actually was and tells the agent to seek first. `studio_update_animation` rules out the no-selection case BEFORE dispatch. The handler answers `false` for both "nothing selected" and "the write failed", so eliminating one is what makes the other legible. Keyframe percent and properties are validated in the tool, because nothing in the platform checks input against the declared schema. --- .../studio/src/webmcp/StudioAgentTools.tsx | 13 + .../src/webmcp/tools/animationTools.test.ts | 244 ++++++++++++++++ .../studio/src/webmcp/tools/animationTools.ts | 276 ++++++++++++++++++ .../src/webmcp/useStudioAgentTools.test.tsx | 14 +- .../studio/src/webmcp/useStudioAgentTools.ts | 63 +++- 5 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/webmcp/tools/animationTools.test.ts create mode 100644 packages/studio/src/webmcp/tools/animationTools.ts diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index f201ca23bf..bdfa2cba31 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -33,6 +33,10 @@ export function StudioAgentTools() { handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + handleGsapAddAnimation, + handleGsapUpdateMeta, + handleGsapAddKeyframeBatch, + handleGsapDeleteAnimation, } = useDomEditActionsContext(); const getSnapshot = useCallback((): StudioLookSnapshot => { @@ -97,6 +101,11 @@ export function StudioAgentTools() { moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next), resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next), rotateTo: (selection, next) => handleDomRotationCommit(selection, next), + addAnimation: (method) => handleGsapAddAnimation(method), + updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates), + addKeyframe: (animationId, percent, properties) => + handleGsapAddKeyframeBatch(animationId, percent, properties), + deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId), getGsapDiagnostics: () => ({ animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, @@ -116,6 +125,10 @@ export function StudioAgentTools() { handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + handleGsapAddAnimation, + handleGsapUpdateMeta, + handleGsapAddKeyframeBatch, + handleGsapDeleteAnimation, domEditSelection, selectedGsapAnimations, gsapMultipleTimelines, diff --git a/packages/studio/src/webmcp/tools/animationTools.test.ts b/packages/studio/src/webmcp/tools/animationTools.test.ts new file mode 100644 index 0000000000..f8f60bb8d5 --- /dev/null +++ b/packages/studio/src/webmcp/tools/animationTools.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { + studioAddAnimation, + studioAddKeyframe, + studioDeleteAnimation, + studioUpdateAnimation, + type AnimationToolDeps, + type StudioAddAnimationResult, + type StudioAddKeyframeResult, + type StudioUpdateAnimationResult, +} from "./animationTools"; +import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils"; + +function animationDeps(overrides: Partial = {}): AnimationToolDeps { + const element = previewElement('

Ship it

', "headline"); + return { + getCurrentSelection: () => selectionFor(element), + getWriteBlockedReason: () => null, + readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }), + addAnimation: () => undefined, + updateAnimation: async () => true, + addKeyframe: async () => undefined, + deleteAnimation: () => undefined, + ...overrides, + }; +} + +describe("studioAddAnimation", () => { + it("reports where the playhead actually was, not a position the caller chose", async () => { + // The handler reads the playhead itself and ignores any position argument, + // so echoing one back would report a number that had no effect. + const addAnimation = vi.fn(); + + const result = await studioAddAnimation( + animationDeps({ + addAnimation, + readPlayhead: () => ({ currentTime: 7.25, duration: 10, isPlaying: false }), + }), + { method: "from" }, + ); + + const ok = expectOk(result); + expect(ok.insertedAtSeconds).toBe(7.25); + expect(ok.method).toBe("from"); + expect(addAnimation).toHaveBeenCalledWith("from"); + }); + + it("marks the result as dispatched rather than claiming it landed", async () => { + // `handleGsapAddAnimation` is fire-and-forget and returns nothing, so there + // is no honest success signal to report. + const result = await studioAddAnimation(animationDeps(), { method: "to" }); + + expect(expectOk(result).dispatched).toBe(true); + }); + + it("rejects an unknown method without dispatching", async () => { + const addAnimation = vi.fn(); + + const result = expectFailure( + await studioAddAnimation(animationDeps({ addAnimation }), { method: "wiggle" }), + ); + + expect(result.kind).toBe("invalid"); + expect(addAnimation).not.toHaveBeenCalled(); + }); + + it("refuses while a write is blocked, and when nothing is selected", async () => { + const addAnimation = vi.fn(); + + const paused = expectFailure( + await studioAddAnimation( + animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", addAnimation }), + { method: "to" }, + ), + ); + const unselected = expectFailure( + await studioAddAnimation(animationDeps({ getCurrentSelection: () => null, addAnimation }), { + method: "to", + }), + ); + + expect(paused.kind).toBe("blocked"); + expect(unselected.kind).toBe("invalid"); + expect(addAnimation).not.toHaveBeenCalled(); + }); +}); + +describe("studioUpdateAnimation", () => { + it("confirms the write, because this handler actually reports back", async () => { + const updateAnimation = vi.fn(async () => true); + + const result = await studioUpdateAnimation(animationDeps({ updateAnimation }), { + animationId: "anim-1", + ease: "power2.out", + duration: 1.5, + }); + + const ok = expectOk(result); + expect(ok.updated).toEqual({ duration: 1.5, ease: "power2.out" }); + expect(updateAnimation).toHaveBeenCalledWith("anim-1", { + duration: 1.5, + ease: "power2.out", + }); + }); + + it("reports a false return as a real failure", async () => { + const result = expectFailure( + await studioUpdateAnimation(animationDeps({ updateAnimation: async () => false }), { + animationId: "anim-gone", + ease: "none", + }), + ); + + expect(result.kind).toBe("failed"); + expect(result.hint).toMatch(/stale/); + }); + + it("rules out the no-selection case BEFORE dispatch, so a false is unambiguous", async () => { + // The handler answers `false` for both "nothing selected" and "the write + // failed". Eliminating one beforehand is what makes the other legible. + const updateAnimation = vi.fn(async () => false); + + const result = expectFailure( + await studioUpdateAnimation( + animationDeps({ getCurrentSelection: () => null, updateAnimation }), + { animationId: "anim-1", ease: "none" }, + ), + ); + + expect(result.kind).toBe("invalid"); + expect(result.reason).toMatch(/nothing is selected/); + expect(updateAnimation).not.toHaveBeenCalled(); + }); + + it("requires at least one field, and rejects a negative duration", async () => { + const deps = animationDeps(); + + expect(expectFailure(await studioUpdateAnimation(deps, { animationId: "a" })).reason).toMatch( + /at least one/, + ); + expect( + expectFailure(await studioUpdateAnimation(deps, { animationId: "a", duration: -1 })).reason, + ).toMatch(/negative/); + }); + + it("rejects a blank animation id", async () => { + const updateAnimation = vi.fn(); + + const result = expectFailure( + await studioUpdateAnimation(animationDeps({ updateAnimation }), { + animationId: " ", + ease: "none", + }), + ); + + expect(result.kind).toBe("invalid"); + expect(updateAnimation).not.toHaveBeenCalled(); + }); +}); + +describe("studioAddKeyframe", () => { + it("passes every property through in one commit", async () => { + const addKeyframe = vi.fn(async () => undefined); + + const result = await studioAddKeyframe(animationDeps({ addKeyframe }), { + animationId: "anim-1", + percent: 50, + properties: { y: -50, opacity: 0 }, + }); + + const ok = expectOk(result); + expect(ok.properties).toEqual({ y: -50, opacity: 0 }); + // One call, so one undo entry, rather than one per property. + expect(addKeyframe).toHaveBeenCalledTimes(1); + expect(addKeyframe).toHaveBeenCalledWith("anim-1", 50, { y: -50, opacity: 0 }); + }); + + it("validates percent itself, because the platform does not", async () => { + // Nothing checks the input object against inputSchema, so the tool receives + // whatever the agent sent. + const addKeyframe = vi.fn(); + const deps = animationDeps({ addKeyframe }); + + for (const percent of [-1, 101, Number.NaN, "50"]) { + const result = expectFailure( + await studioAddKeyframe(deps, { animationId: "a", percent, properties: { y: 1 } }), + ); + expect(result.kind).toBe("invalid"); + } + expect(addKeyframe).not.toHaveBeenCalled(); + }); + + it("rejects properties that carry no usable value", async () => { + const addKeyframe = vi.fn(); + const deps = animationDeps({ addKeyframe }); + + for (const properties of [{}, { y: null }, [], "y:1"]) { + const result = expectFailure( + await studioAddKeyframe(deps, { animationId: "a", percent: 50, properties }), + ); + expect(result.kind).toBe("invalid"); + } + expect(addKeyframe).not.toHaveBeenCalled(); + }); + + it("accepts 0 and 100 as the ends of the tween", async () => { + for (const percent of [0, 100]) { + const result = await studioAddKeyframe(animationDeps(), { + animationId: "a", + percent, + properties: { y: 1 }, + }); + expect(expectOk(result).percent).toBe(percent); + } + }); +}); + +describe("studioDeleteAnimation", () => { + it("dispatches the delete and says so", async () => { + const deleteAnimation = vi.fn(); + + const result = await studioDeleteAnimation(animationDeps({ deleteAnimation }), { + animationId: "anim-1", + }); + + expect(result.ok).toBe(true); + expect(deleteAnimation).toHaveBeenCalledWith("anim-1"); + }); + + it("refuses while a write is blocked", async () => { + const deleteAnimation = vi.fn(); + + const result = expectFailure( + await studioDeleteAnimation( + animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", deleteAnimation }), + { animationId: "anim-1" }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(deleteAnimation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/webmcp/tools/animationTools.ts b/packages/studio/src/webmcp/tools/animationTools.ts new file mode 100644 index 0000000000..0d5d678bc4 --- /dev/null +++ b/packages/studio/src/webmcp/tools/animationTools.ts @@ -0,0 +1,276 @@ +/** + * `studio_animate`: author motion. + * + * These tools are deliberately less confident than the rest, because the + * handlers underneath them are: + * + * - `handleGsapAddAnimation(method)` takes ONLY a method. Its insert position + * comes from the live playhead, not from the caller, and the call is + * `void ...catch()`, so it returns nothing and cannot be awaited. + * - `handleGsapAddKeyframeBatch` returns a promise but catches its own failure, + * so awaiting it proves the call finished, not that it landed. + * - `handleGsapDeleteAnimation` discards its promise entirely. + * - `handleGsapUpdateMeta` is the one honest signal: it returns a boolean. + * Its `false` is ambiguous though, meaning either no selection or a failed + * write, so the no-selection case is ruled out before dispatch. + * + * U8 solved the same problem by reading the result back. That does not work + * here: the animation list comes from React state that only refreshes on a + * render, and no render happens inside one tool call. So rather than fake a + * verification, these report what was dispatched and tell the agent to call + * `studio_inspect` to see the result. Saying "I asked for this" is honest; + * saying "this happened" would not be. + */ + +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult"; + +export type GsapMethod = "to" | "from" | "set" | "fromTo"; + +const METHODS: readonly GsapMethod[] = ["to", "from", "set", "fromTo"]; + +export interface AnimationToolDeps { + getCurrentSelection: () => DomEditSelection | null; + getWriteBlockedReason: () => string | null; + readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean }; + addAnimation: (method: GsapMethod) => void; + updateAnimation: ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => Promise; + addKeyframe: ( + animationId: string, + percent: number, + properties: Record, + ) => Promise; + deleteAnimation: (animationId: string) => void; +} + +const INSPECT_HINT = "Call studio_inspect to see the result."; + +function guard(deps: AnimationToolDeps): ToolFailure | null { + const blocked = deps.getWriteBlockedReason(); + if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry."); + if (!deps.getCurrentSelection()) { + return toolFailure("invalid", "nothing is selected", "Call studio_select first."); + } + return null; +} + +function readAnimationId(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +export interface StudioAddAnimationResult { + method: GsapMethod; + /** Where it was inserted, which is the playhead, not a value you supplied. */ + insertedAtSeconds: number; + dispatched: true; +} + +export async function studioAddAnimation( + deps: AnimationToolDeps, + input: { method?: unknown }, +): Promise> { + const method = METHODS.find((candidate) => candidate === input.method); + if (!method) { + return toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + // The handler reads the playhead itself. Reporting a position the caller gave + // us would be reporting a number that had no effect, so the tool takes no + // position and reports where the playhead actually is instead. + const { currentTime } = deps.readPlayhead(); + deps.addAnimation(method); + + return toolOk({ + method, + insertedAtSeconds: currentTime, + dispatched: true, + }); +} + +export interface StudioUpdateAnimationResult { + animationId: string; + updated: { duration?: number; ease?: string; position?: number }; +} + +export async function studioUpdateAnimation( + deps: AnimationToolDeps, + input: { animationId?: unknown; duration?: unknown; ease?: unknown; position?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + + const updates: { duration?: number; ease?: string; position?: number } = {}; + if (typeof input.duration === "number" && Number.isFinite(input.duration)) { + if (input.duration < 0) return toolFailure("invalid", "duration must not be negative"); + updates.duration = input.duration; + } + if (typeof input.ease === "string" && input.ease.trim()) updates.ease = input.ease; + if (typeof input.position === "number" && Number.isFinite(input.position)) { + updates.position = input.position; + } + if (Object.keys(updates).length === 0) { + return toolFailure("invalid", "give at least one of duration, ease, position"); + } + + // Ruled out BEFORE dispatch on purpose: the handler answers `false` for both + // "nothing selected" and "the write failed", so a false afterwards would be + // ambiguous. Eliminating one of the two makes the other one legible. + const blocked = guard(deps); + if (blocked) return blocked; + + const landed = await deps.updateAnimation(animationId, updates); + if (!landed) { + return toolFailure( + "failed", + `the update to ${animationId} did not land`, + "The animation id may be stale. studio_inspect lists the current ones.", + ); + } + + return toolOk({ animationId, updated: updates }); +} + +export interface StudioAddKeyframeResult { + animationId: string; + percent: number; + properties: Record; + dispatched: true; +} + +export async function studioAddKeyframe( + deps: AnimationToolDeps, + input: { animationId?: unknown; percent?: unknown; properties?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + const percent = input.percent; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { + // Validated here because nothing in the platform checks input against the + // schema; the tool receives whatever the agent sent. + return toolFailure("invalid", "percent must be a number between 0 and 100"); + } + const raw = input.properties; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return toolFailure("invalid", "properties must be an object of GSAP property to value"); + } + const properties: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "number" || typeof value === "string") properties[key] = value; + } + if (Object.keys(properties).length === 0) { + return toolFailure("invalid", "properties must contain at least one number or string value"); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + await deps.addKeyframe(animationId, percent, properties); + + return toolOk({ animationId, percent, properties, dispatched: true }); +} + +export interface StudioDeleteAnimationResult { + animationId: string; + dispatched: true; +} + +export async function studioDeleteAnimation( + deps: AnimationToolDeps, + input: { animationId?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + deps.deleteAnimation(animationId); + return toolOk({ animationId, dispatched: true }); +} + +const DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the handler underneath does not report back. ${INSPECT_HINT}`; + +export const STUDIO_ADD_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + method: { type: "string", enum: METHODS, description: "The GSAP method to add." }, + }, + required: ["method"], + additionalProperties: false, +} as const; + +export const STUDIO_ADD_ANIMATION_DESCRIPTION = [ + "Add a GSAP animation to the CURRENTLY SELECTED element. Call studio_select first.", + "It is inserted AT THE PLAYHEAD, which this tool does not control: call studio_seek first", + "to choose when it starts. The result reports where the playhead actually was.", + DISPATCH_CAVEAT, +].join(" "); + +export const STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + duration: { type: "number", minimum: 0, description: "Duration in seconds." }, + ease: { type: "string", description: "A GSAP ease, for example power2.out." }, + position: { type: "number", description: "Start position in seconds." }, + }, + required: ["animationId"], + additionalProperties: false, +} as const; + +export const STUDIO_UPDATE_ANIMATION_DESCRIPTION = [ + "Change an existing animation's duration, ease or position.", + "This is the one animation tool that CONFIRMS its write, so a failure here is real", + "and usually means a stale animationId. Get current ids from studio_inspect.", +].join(" "); + +export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + percent: { + type: "number", + minimum: 0, + maximum: 100, + description: "Where in the tween, 0 to 100.", + }, + properties: { + type: "object", + description: 'GSAP property to value, for example {"y": -50, "opacity": 0}.', + }, + }, + required: ["animationId", "percent", "properties"], + additionalProperties: false, +} as const; + +export const STUDIO_ADD_KEYFRAME_DESCRIPTION = [ + "Add a keyframe to an existing animation at a percentage through it.", + "All the properties land in one commit, so they are one undo entry.", + DISPATCH_CAVEAT, +].join(" "); + +export const STUDIO_DELETE_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + }, + required: ["animationId"], + additionalProperties: false, +} as const; + +export const STUDIO_DELETE_ANIMATION_DESCRIPTION = [ + "Remove an animation from the currently selected element. Undo reverses it.", + DISPATCH_CAVEAT, +].join(" "); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index 274b697c91..b797cc0f87 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -50,6 +50,10 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe moveTo: async () => undefined, resizeTo: async () => undefined, rotateTo: async () => undefined, + addAnimation: () => undefined, + updateAnimation: async () => true, + addKeyframe: async () => undefined, + deleteAnimation: () => undefined, getGsapDiagnostics: () => ({ animations: [], multipleTimelines: false, @@ -124,6 +128,10 @@ describe("useStudioAgentTools", () => { "studio_set_text", "studio_set_style", "studio_transform", + "studio_add_animation", + "studio_update_animation", + "studio_add_keyframe", + "studio_delete_animation", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -138,14 +146,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -218,7 +226,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); }); it("reports a non-abort registration failure through production telemetry", async () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index f62c369bcd..3402dc66c7 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -60,6 +60,25 @@ import { type StudioTransformResult, type TransformToolDeps, } from "./tools/transformTools"; +import { + studioAddAnimation, + studioAddKeyframe, + studioDeleteAnimation, + studioUpdateAnimation, + STUDIO_ADD_ANIMATION_DESCRIPTION, + STUDIO_ADD_ANIMATION_INPUT_SCHEMA, + STUDIO_ADD_KEYFRAME_DESCRIPTION, + STUDIO_ADD_KEYFRAME_INPUT_SCHEMA, + STUDIO_DELETE_ANIMATION_DESCRIPTION, + STUDIO_DELETE_ANIMATION_INPUT_SCHEMA, + STUDIO_UPDATE_ANIMATION_DESCRIPTION, + STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA, + type AnimationToolDeps, + type StudioAddAnimationResult, + type StudioAddKeyframeResult, + type StudioDeleteAnimationResult, + type StudioUpdateAnimationResult, +} from "./tools/animationTools"; const log = makeStudioDebugLogger("webmcp"); @@ -74,7 +93,13 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } export interface StudioAgentToolsDeps - extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps, TransformToolDeps { + extends + SelectionToolDeps, + FrameToolDeps, + InspectToolDeps, + ContentToolDeps, + TransformToolDeps, + AnimationToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -175,6 +200,42 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): studioTransform(depsRef.current, input as StudioTransformInput), ), }, + { + name: "studio_add_animation", + title: "Add an animation", + description: STUDIO_ADD_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_add_animation", () => studioAddAnimation(depsRef.current, input)), + }, + { + name: "studio_update_animation", + title: "Change an animation", + description: STUDIO_UPDATE_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_update_animation", () => studioUpdateAnimation(depsRef.current, input)), + }, + { + name: "studio_add_keyframe", + title: "Add a keyframe", + description: STUDIO_ADD_KEYFRAME_DESCRIPTION, + inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_add_keyframe", () => studioAddKeyframe(depsRef.current, input)), + }, + { + name: "studio_delete_animation", + title: "Remove an animation", + description: STUDIO_DELETE_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_delete_animation", () => studioDeleteAnimation(depsRef.current, input)), + }, ]; }