@hyperframes/studio 0.8.20 → 0.8.21

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 (37) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-BL1zYTW0.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-Cehosfnu.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-CBSKXKAr.js → index-CgGY8dAv.js} +164 -164
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1170 -227
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +2 -0
  11. package/src/contexts/StudioContext.tsx +10 -0
  12. package/src/hooks/useStudioContextValue.ts +9 -0
  13. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  14. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  15. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  16. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  17. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  18. package/src/player/store/playerStore.ts +17 -2
  19. package/src/player/store/timelineElement.ts +21 -0
  20. package/src/webmcp/StudioAgentTools.tsx +105 -10
  21. package/src/webmcp/toolResult.ts +1 -1
  22. package/src/webmcp/tools/animationTools.test.ts +244 -0
  23. package/src/webmcp/tools/animationTools.ts +276 -0
  24. package/src/webmcp/tools/contentTools.test.ts +275 -0
  25. package/src/webmcp/tools/contentTools.ts +217 -0
  26. package/src/webmcp/tools/frameTools.test.ts +136 -0
  27. package/src/webmcp/tools/frameTools.ts +133 -0
  28. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  29. package/src/webmcp/tools/inspectTools.ts +208 -0
  30. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  31. package/src/webmcp/tools/selectionTools.ts +154 -0
  32. package/src/webmcp/tools/transformTools.test.ts +179 -0
  33. package/src/webmcp/tools/transformTools.ts +205 -0
  34. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  35. package/src/webmcp/useStudioAgentTools.ts +195 -1
  36. package/src/webmcp/webmcpTestUtils.ts +91 -0
  37. package/dist/assets/hyperframes-player-BEKxuimO.js +0 -459
@@ -1,8 +1,8 @@
1
- import { useCallback } from "react";
2
- import { useDomEditSelectionContext } from "../contexts/DomEditContext";
1
+ import { useCallback, useMemo } from "react";
2
+ import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePlayerStore } from "../player";
5
- import { useStudioAgentTools } from "./useStudioAgentTools";
5
+ import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
6
6
  import type { StudioLookSnapshot } from "./tools/lookTools";
7
7
 
8
8
  /**
@@ -12,14 +12,32 @@ import type { StudioLookSnapshot } from "./tools/lookTools";
12
12
  * contexts are only readable below `DomEditProvider`, which `App` renders, and
13
13
  * `App.tsx` sits three lines under the 600-line cap.
14
14
  *
15
- * The player store is read IMPERATIVELY through `getState()` inside the
16
- * snapshot callback rather than subscribed to. Subscribing to `currentTime`
17
- * would re-render this component on every animation frame during playback for
18
- * a value nothing here displays.
15
+ * The player store is read IMPERATIVELY through `getState()` rather than
16
+ * subscribed to. Subscribing to `currentTime` would re-render this component on
17
+ * every animation frame during playback for a value nothing here displays.
19
18
  */
20
19
  export function StudioAgentTools() {
21
- const { projectId, activeCompPath, editHistory } = useStudioShellContext();
22
- const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
20
+ const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext();
21
+ const {
22
+ domEditSelection,
23
+ selectedGsapAnimations,
24
+ gsapMultipleTimelines,
25
+ gsapUnsupportedTimelinePattern,
26
+ } = useDomEditSelectionContext();
27
+ const {
28
+ previewIframeRef,
29
+ buildDomSelectionFromTarget,
30
+ applyDomSelection,
31
+ handleDomTextCommit,
32
+ handleDomStyleCommit,
33
+ handleDomPathOffsetCommit,
34
+ handleDomBoxSizeCommit,
35
+ handleDomRotationCommit,
36
+ handleGsapAddAnimation,
37
+ handleGsapUpdateMeta,
38
+ handleGsapAddKeyframeBatch,
39
+ handleGsapDeleteAnimation,
40
+ } = useDomEditActionsContext();
23
41
 
24
42
  const getSnapshot = useCallback((): StudioLookSnapshot => {
25
43
  const player = usePlayerStore.getState();
@@ -41,6 +59,83 @@ export function StudioAgentTools() {
41
59
  };
42
60
  }, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
43
61
 
44
- useStudioAgentTools({ getSnapshot });
62
+ const deps = useMemo<StudioAgentToolsDeps>(
63
+ () => ({
64
+ getSnapshot,
65
+ getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
66
+ buildSelection: (element) => buildDomSelectionFromTarget(element),
67
+ applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
68
+ requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
69
+ readPlayhead: () => {
70
+ const player = usePlayerStore.getState();
71
+ return {
72
+ currentTime: player.currentTime,
73
+ duration: player.duration,
74
+ isPlaying: player.isPlaying,
75
+ };
76
+ },
77
+ getProjectId: () => projectId,
78
+ getCompositionPath: () => activeCompPath,
79
+ // HEAD, not GET: the tool only needs to know the frame renders. Pulling
80
+ // the PNG here would download it once for nothing, since the agent
81
+ // fetches the URL itself.
82
+ probeFrame: async (url) => {
83
+ try {
84
+ const response = await fetch(url, { method: "HEAD" });
85
+ return { ok: response.ok, status: response.status };
86
+ } catch {
87
+ return { ok: false, status: 0 };
88
+ }
89
+ },
90
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
91
+ getCurrentSelection: () => domEditSelection,
92
+ getWriteBlockedReason: () => writeBlockedReason,
93
+ setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey),
94
+ setStyle: (property, value) => handleDomStyleCommit(property, value),
95
+ // Measured, not authored: the tool compares this before and after to
96
+ // tell a real change from a handler that did nothing and resolved.
97
+ readBox: (selection) => {
98
+ const rect = selection.element.getBoundingClientRect();
99
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
100
+ },
101
+ moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
102
+ resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next),
103
+ rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
104
+ addAnimation: (method) => handleGsapAddAnimation(method),
105
+ updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates),
106
+ addKeyframe: (animationId, percent, properties) =>
107
+ handleGsapAddKeyframeBatch(animationId, percent, properties),
108
+ deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId),
109
+ getGsapDiagnostics: () => ({
110
+ animations: selectedGsapAnimations,
111
+ multipleTimelines: gsapMultipleTimelines,
112
+ unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
113
+ }),
114
+ }),
115
+ [
116
+ getSnapshot,
117
+ previewIframeRef,
118
+ buildDomSelectionFromTarget,
119
+ applyDomSelection,
120
+ projectId,
121
+ activeCompPath,
122
+ writeBlockedReason,
123
+ handleDomTextCommit,
124
+ handleDomStyleCommit,
125
+ handleDomPathOffsetCommit,
126
+ handleDomBoxSizeCommit,
127
+ handleDomRotationCommit,
128
+ handleGsapAddAnimation,
129
+ handleGsapUpdateMeta,
130
+ handleGsapAddKeyframeBatch,
131
+ handleGsapDeleteAnimation,
132
+ domEditSelection,
133
+ selectedGsapAnimations,
134
+ gsapMultipleTimelines,
135
+ gsapUnsupportedTimelinePattern,
136
+ ],
137
+ );
138
+
139
+ useStudioAgentTools(deps);
45
140
  return null;
46
141
  }
@@ -36,7 +36,7 @@ export function toolOk<T extends object>(value: T): { ok: true } & T {
36
36
  return { ok: true, ...value };
37
37
  }
38
38
 
39
- function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
39
+ export function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
40
40
  return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
41
41
  }
42
42
 
@@ -0,0 +1,244 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ studioAddAnimation,
5
+ studioAddKeyframe,
6
+ studioDeleteAnimation,
7
+ studioUpdateAnimation,
8
+ type AnimationToolDeps,
9
+ type StudioAddAnimationResult,
10
+ type StudioAddKeyframeResult,
11
+ type StudioUpdateAnimationResult,
12
+ } from "./animationTools";
13
+ import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
14
+
15
+ function animationDeps(overrides: Partial<AnimationToolDeps> = {}): AnimationToolDeps {
16
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
17
+ return {
18
+ getCurrentSelection: () => selectionFor(element),
19
+ getWriteBlockedReason: () => null,
20
+ readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }),
21
+ addAnimation: () => undefined,
22
+ updateAnimation: async () => true,
23
+ addKeyframe: async () => undefined,
24
+ deleteAnimation: () => undefined,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe("studioAddAnimation", () => {
30
+ it("reports where the playhead actually was, not a position the caller chose", async () => {
31
+ // The handler reads the playhead itself and ignores any position argument,
32
+ // so echoing one back would report a number that had no effect.
33
+ const addAnimation = vi.fn();
34
+
35
+ const result = await studioAddAnimation(
36
+ animationDeps({
37
+ addAnimation,
38
+ readPlayhead: () => ({ currentTime: 7.25, duration: 10, isPlaying: false }),
39
+ }),
40
+ { method: "from" },
41
+ );
42
+
43
+ const ok = expectOk<StudioAddAnimationResult>(result);
44
+ expect(ok.insertedAtSeconds).toBe(7.25);
45
+ expect(ok.method).toBe("from");
46
+ expect(addAnimation).toHaveBeenCalledWith("from");
47
+ });
48
+
49
+ it("marks the result as dispatched rather than claiming it landed", async () => {
50
+ // `handleGsapAddAnimation` is fire-and-forget and returns nothing, so there
51
+ // is no honest success signal to report.
52
+ const result = await studioAddAnimation(animationDeps(), { method: "to" });
53
+
54
+ expect(expectOk<StudioAddAnimationResult>(result).dispatched).toBe(true);
55
+ });
56
+
57
+ it("rejects an unknown method without dispatching", async () => {
58
+ const addAnimation = vi.fn();
59
+
60
+ const result = expectFailure(
61
+ await studioAddAnimation(animationDeps({ addAnimation }), { method: "wiggle" }),
62
+ );
63
+
64
+ expect(result.kind).toBe("invalid");
65
+ expect(addAnimation).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("refuses while a write is blocked, and when nothing is selected", async () => {
69
+ const addAnimation = vi.fn();
70
+
71
+ const paused = expectFailure(
72
+ await studioAddAnimation(
73
+ animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", addAnimation }),
74
+ { method: "to" },
75
+ ),
76
+ );
77
+ const unselected = expectFailure(
78
+ await studioAddAnimation(animationDeps({ getCurrentSelection: () => null, addAnimation }), {
79
+ method: "to",
80
+ }),
81
+ );
82
+
83
+ expect(paused.kind).toBe("blocked");
84
+ expect(unselected.kind).toBe("invalid");
85
+ expect(addAnimation).not.toHaveBeenCalled();
86
+ });
87
+ });
88
+
89
+ describe("studioUpdateAnimation", () => {
90
+ it("confirms the write, because this handler actually reports back", async () => {
91
+ const updateAnimation = vi.fn(async () => true);
92
+
93
+ const result = await studioUpdateAnimation(animationDeps({ updateAnimation }), {
94
+ animationId: "anim-1",
95
+ ease: "power2.out",
96
+ duration: 1.5,
97
+ });
98
+
99
+ const ok = expectOk<StudioUpdateAnimationResult>(result);
100
+ expect(ok.updated).toEqual({ duration: 1.5, ease: "power2.out" });
101
+ expect(updateAnimation).toHaveBeenCalledWith("anim-1", {
102
+ duration: 1.5,
103
+ ease: "power2.out",
104
+ });
105
+ });
106
+
107
+ it("reports a false return as a real failure", async () => {
108
+ const result = expectFailure(
109
+ await studioUpdateAnimation(animationDeps({ updateAnimation: async () => false }), {
110
+ animationId: "anim-gone",
111
+ ease: "none",
112
+ }),
113
+ );
114
+
115
+ expect(result.kind).toBe("failed");
116
+ expect(result.hint).toMatch(/stale/);
117
+ });
118
+
119
+ it("rules out the no-selection case BEFORE dispatch, so a false is unambiguous", async () => {
120
+ // The handler answers `false` for both "nothing selected" and "the write
121
+ // failed". Eliminating one beforehand is what makes the other legible.
122
+ const updateAnimation = vi.fn(async () => false);
123
+
124
+ const result = expectFailure(
125
+ await studioUpdateAnimation(
126
+ animationDeps({ getCurrentSelection: () => null, updateAnimation }),
127
+ { animationId: "anim-1", ease: "none" },
128
+ ),
129
+ );
130
+
131
+ expect(result.kind).toBe("invalid");
132
+ expect(result.reason).toMatch(/nothing is selected/);
133
+ expect(updateAnimation).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("requires at least one field, and rejects a negative duration", async () => {
137
+ const deps = animationDeps();
138
+
139
+ expect(expectFailure(await studioUpdateAnimation(deps, { animationId: "a" })).reason).toMatch(
140
+ /at least one/,
141
+ );
142
+ expect(
143
+ expectFailure(await studioUpdateAnimation(deps, { animationId: "a", duration: -1 })).reason,
144
+ ).toMatch(/negative/);
145
+ });
146
+
147
+ it("rejects a blank animation id", async () => {
148
+ const updateAnimation = vi.fn();
149
+
150
+ const result = expectFailure(
151
+ await studioUpdateAnimation(animationDeps({ updateAnimation }), {
152
+ animationId: " ",
153
+ ease: "none",
154
+ }),
155
+ );
156
+
157
+ expect(result.kind).toBe("invalid");
158
+ expect(updateAnimation).not.toHaveBeenCalled();
159
+ });
160
+ });
161
+
162
+ describe("studioAddKeyframe", () => {
163
+ it("passes every property through in one commit", async () => {
164
+ const addKeyframe = vi.fn(async () => undefined);
165
+
166
+ const result = await studioAddKeyframe(animationDeps({ addKeyframe }), {
167
+ animationId: "anim-1",
168
+ percent: 50,
169
+ properties: { y: -50, opacity: 0 },
170
+ });
171
+
172
+ const ok = expectOk<StudioAddKeyframeResult>(result);
173
+ expect(ok.properties).toEqual({ y: -50, opacity: 0 });
174
+ // One call, so one undo entry, rather than one per property.
175
+ expect(addKeyframe).toHaveBeenCalledTimes(1);
176
+ expect(addKeyframe).toHaveBeenCalledWith("anim-1", 50, { y: -50, opacity: 0 });
177
+ });
178
+
179
+ it("validates percent itself, because the platform does not", async () => {
180
+ // Nothing checks the input object against inputSchema, so the tool receives
181
+ // whatever the agent sent.
182
+ const addKeyframe = vi.fn();
183
+ const deps = animationDeps({ addKeyframe });
184
+
185
+ for (const percent of [-1, 101, Number.NaN, "50"]) {
186
+ const result = expectFailure(
187
+ await studioAddKeyframe(deps, { animationId: "a", percent, properties: { y: 1 } }),
188
+ );
189
+ expect(result.kind).toBe("invalid");
190
+ }
191
+ expect(addKeyframe).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it("rejects properties that carry no usable value", async () => {
195
+ const addKeyframe = vi.fn();
196
+ const deps = animationDeps({ addKeyframe });
197
+
198
+ for (const properties of [{}, { y: null }, [], "y:1"]) {
199
+ const result = expectFailure(
200
+ await studioAddKeyframe(deps, { animationId: "a", percent: 50, properties }),
201
+ );
202
+ expect(result.kind).toBe("invalid");
203
+ }
204
+ expect(addKeyframe).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("accepts 0 and 100 as the ends of the tween", async () => {
208
+ for (const percent of [0, 100]) {
209
+ const result = await studioAddKeyframe(animationDeps(), {
210
+ animationId: "a",
211
+ percent,
212
+ properties: { y: 1 },
213
+ });
214
+ expect(expectOk<StudioAddKeyframeResult>(result).percent).toBe(percent);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe("studioDeleteAnimation", () => {
220
+ it("dispatches the delete and says so", async () => {
221
+ const deleteAnimation = vi.fn();
222
+
223
+ const result = await studioDeleteAnimation(animationDeps({ deleteAnimation }), {
224
+ animationId: "anim-1",
225
+ });
226
+
227
+ expect(result.ok).toBe(true);
228
+ expect(deleteAnimation).toHaveBeenCalledWith("anim-1");
229
+ });
230
+
231
+ it("refuses while a write is blocked", async () => {
232
+ const deleteAnimation = vi.fn();
233
+
234
+ const result = expectFailure(
235
+ await studioDeleteAnimation(
236
+ animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", deleteAnimation }),
237
+ { animationId: "anim-1" },
238
+ ),
239
+ );
240
+
241
+ expect(result.kind).toBe("blocked");
242
+ expect(deleteAnimation).not.toHaveBeenCalled();
243
+ });
244
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `studio_animate`: author motion.
3
+ *
4
+ * These tools are deliberately less confident than the rest, because the
5
+ * handlers underneath them are:
6
+ *
7
+ * - `handleGsapAddAnimation(method)` takes ONLY a method. Its insert position
8
+ * comes from the live playhead, not from the caller, and the call is
9
+ * `void ...catch()`, so it returns nothing and cannot be awaited.
10
+ * - `handleGsapAddKeyframeBatch` returns a promise but catches its own failure,
11
+ * so awaiting it proves the call finished, not that it landed.
12
+ * - `handleGsapDeleteAnimation` discards its promise entirely.
13
+ * - `handleGsapUpdateMeta` is the one honest signal: it returns a boolean.
14
+ * Its `false` is ambiguous though, meaning either no selection or a failed
15
+ * write, so the no-selection case is ruled out before dispatch.
16
+ *
17
+ * U8 solved the same problem by reading the result back. That does not work
18
+ * here: the animation list comes from React state that only refreshes on a
19
+ * render, and no render happens inside one tool call. So rather than fake a
20
+ * verification, these report what was dispatched and tell the agent to call
21
+ * `studio_inspect` to see the result. Saying "I asked for this" is honest;
22
+ * saying "this happened" would not be.
23
+ */
24
+
25
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
26
+ import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
27
+
28
+ export type GsapMethod = "to" | "from" | "set" | "fromTo";
29
+
30
+ const METHODS: readonly GsapMethod[] = ["to", "from", "set", "fromTo"];
31
+
32
+ export interface AnimationToolDeps {
33
+ getCurrentSelection: () => DomEditSelection | null;
34
+ getWriteBlockedReason: () => string | null;
35
+ readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean };
36
+ addAnimation: (method: GsapMethod) => void;
37
+ updateAnimation: (
38
+ animationId: string,
39
+ updates: { duration?: number; ease?: string; position?: number },
40
+ ) => Promise<boolean>;
41
+ addKeyframe: (
42
+ animationId: string,
43
+ percent: number,
44
+ properties: Record<string, number | string>,
45
+ ) => Promise<void>;
46
+ deleteAnimation: (animationId: string) => void;
47
+ }
48
+
49
+ const INSPECT_HINT = "Call studio_inspect to see the result.";
50
+
51
+ function guard(deps: AnimationToolDeps): ToolFailure | null {
52
+ const blocked = deps.getWriteBlockedReason();
53
+ if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
54
+ if (!deps.getCurrentSelection()) {
55
+ return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function readAnimationId(value: unknown): string | null {
61
+ return typeof value === "string" && value.trim() ? value : null;
62
+ }
63
+
64
+ export interface StudioAddAnimationResult {
65
+ method: GsapMethod;
66
+ /** Where it was inserted, which is the playhead, not a value you supplied. */
67
+ insertedAtSeconds: number;
68
+ dispatched: true;
69
+ }
70
+
71
+ export async function studioAddAnimation(
72
+ deps: AnimationToolDeps,
73
+ input: { method?: unknown },
74
+ ): Promise<ToolResult<StudioAddAnimationResult>> {
75
+ const method = METHODS.find((candidate) => candidate === input.method);
76
+ if (!method) {
77
+ return toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`);
78
+ }
79
+
80
+ const blocked = guard(deps);
81
+ if (blocked) return blocked;
82
+
83
+ // The handler reads the playhead itself. Reporting a position the caller gave
84
+ // us would be reporting a number that had no effect, so the tool takes no
85
+ // position and reports where the playhead actually is instead.
86
+ const { currentTime } = deps.readPlayhead();
87
+ deps.addAnimation(method);
88
+
89
+ return toolOk<StudioAddAnimationResult>({
90
+ method,
91
+ insertedAtSeconds: currentTime,
92
+ dispatched: true,
93
+ });
94
+ }
95
+
96
+ export interface StudioUpdateAnimationResult {
97
+ animationId: string;
98
+ updated: { duration?: number; ease?: string; position?: number };
99
+ }
100
+
101
+ export async function studioUpdateAnimation(
102
+ deps: AnimationToolDeps,
103
+ input: { animationId?: unknown; duration?: unknown; ease?: unknown; position?: unknown },
104
+ ): Promise<ToolResult<StudioUpdateAnimationResult>> {
105
+ const animationId = readAnimationId(input.animationId);
106
+ if (!animationId) {
107
+ return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
108
+ }
109
+
110
+ const updates: { duration?: number; ease?: string; position?: number } = {};
111
+ if (typeof input.duration === "number" && Number.isFinite(input.duration)) {
112
+ if (input.duration < 0) return toolFailure("invalid", "duration must not be negative");
113
+ updates.duration = input.duration;
114
+ }
115
+ if (typeof input.ease === "string" && input.ease.trim()) updates.ease = input.ease;
116
+ if (typeof input.position === "number" && Number.isFinite(input.position)) {
117
+ updates.position = input.position;
118
+ }
119
+ if (Object.keys(updates).length === 0) {
120
+ return toolFailure("invalid", "give at least one of duration, ease, position");
121
+ }
122
+
123
+ // Ruled out BEFORE dispatch on purpose: the handler answers `false` for both
124
+ // "nothing selected" and "the write failed", so a false afterwards would be
125
+ // ambiguous. Eliminating one of the two makes the other one legible.
126
+ const blocked = guard(deps);
127
+ if (blocked) return blocked;
128
+
129
+ const landed = await deps.updateAnimation(animationId, updates);
130
+ if (!landed) {
131
+ return toolFailure(
132
+ "failed",
133
+ `the update to ${animationId} did not land`,
134
+ "The animation id may be stale. studio_inspect lists the current ones.",
135
+ );
136
+ }
137
+
138
+ return toolOk<StudioUpdateAnimationResult>({ animationId, updated: updates });
139
+ }
140
+
141
+ export interface StudioAddKeyframeResult {
142
+ animationId: string;
143
+ percent: number;
144
+ properties: Record<string, number | string>;
145
+ dispatched: true;
146
+ }
147
+
148
+ export async function studioAddKeyframe(
149
+ deps: AnimationToolDeps,
150
+ input: { animationId?: unknown; percent?: unknown; properties?: unknown },
151
+ ): Promise<ToolResult<StudioAddKeyframeResult>> {
152
+ const animationId = readAnimationId(input.animationId);
153
+ if (!animationId) {
154
+ return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
155
+ }
156
+ const percent = input.percent;
157
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
158
+ // Validated here because nothing in the platform checks input against the
159
+ // schema; the tool receives whatever the agent sent.
160
+ return toolFailure("invalid", "percent must be a number between 0 and 100");
161
+ }
162
+ const raw = input.properties;
163
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
164
+ return toolFailure("invalid", "properties must be an object of GSAP property to value");
165
+ }
166
+ const properties: Record<string, number | string> = {};
167
+ for (const [key, value] of Object.entries(raw)) {
168
+ if (typeof value === "number" || typeof value === "string") properties[key] = value;
169
+ }
170
+ if (Object.keys(properties).length === 0) {
171
+ return toolFailure("invalid", "properties must contain at least one number or string value");
172
+ }
173
+
174
+ const blocked = guard(deps);
175
+ if (blocked) return blocked;
176
+
177
+ await deps.addKeyframe(animationId, percent, properties);
178
+
179
+ return toolOk<StudioAddKeyframeResult>({ animationId, percent, properties, dispatched: true });
180
+ }
181
+
182
+ export interface StudioDeleteAnimationResult {
183
+ animationId: string;
184
+ dispatched: true;
185
+ }
186
+
187
+ export async function studioDeleteAnimation(
188
+ deps: AnimationToolDeps,
189
+ input: { animationId?: unknown },
190
+ ): Promise<ToolResult<StudioDeleteAnimationResult>> {
191
+ const animationId = readAnimationId(input.animationId);
192
+ if (!animationId) {
193
+ return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
194
+ }
195
+
196
+ const blocked = guard(deps);
197
+ if (blocked) return blocked;
198
+
199
+ deps.deleteAnimation(animationId);
200
+ return toolOk<StudioDeleteAnimationResult>({ animationId, dispatched: true });
201
+ }
202
+
203
+ const DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the handler underneath does not report back. ${INSPECT_HINT}`;
204
+
205
+ export const STUDIO_ADD_ANIMATION_INPUT_SCHEMA = {
206
+ type: "object",
207
+ properties: {
208
+ method: { type: "string", enum: METHODS, description: "The GSAP method to add." },
209
+ },
210
+ required: ["method"],
211
+ additionalProperties: false,
212
+ } as const;
213
+
214
+ export const STUDIO_ADD_ANIMATION_DESCRIPTION = [
215
+ "Add a GSAP animation to the CURRENTLY SELECTED element. Call studio_select first.",
216
+ "It is inserted AT THE PLAYHEAD, which this tool does not control: call studio_seek first",
217
+ "to choose when it starts. The result reports where the playhead actually was.",
218
+ DISPATCH_CAVEAT,
219
+ ].join(" ");
220
+
221
+ export const STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA = {
222
+ type: "object",
223
+ properties: {
224
+ animationId: { type: "string", description: "An animation id from studio_inspect." },
225
+ duration: { type: "number", minimum: 0, description: "Duration in seconds." },
226
+ ease: { type: "string", description: "A GSAP ease, for example power2.out." },
227
+ position: { type: "number", description: "Start position in seconds." },
228
+ },
229
+ required: ["animationId"],
230
+ additionalProperties: false,
231
+ } as const;
232
+
233
+ export const STUDIO_UPDATE_ANIMATION_DESCRIPTION = [
234
+ "Change an existing animation's duration, ease or position.",
235
+ "This is the one animation tool that CONFIRMS its write, so a failure here is real",
236
+ "and usually means a stale animationId. Get current ids from studio_inspect.",
237
+ ].join(" ");
238
+
239
+ export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = {
240
+ type: "object",
241
+ properties: {
242
+ animationId: { type: "string", description: "An animation id from studio_inspect." },
243
+ percent: {
244
+ type: "number",
245
+ minimum: 0,
246
+ maximum: 100,
247
+ description: "Where in the tween, 0 to 100.",
248
+ },
249
+ properties: {
250
+ type: "object",
251
+ description: 'GSAP property to value, for example {"y": -50, "opacity": 0}.',
252
+ },
253
+ },
254
+ required: ["animationId", "percent", "properties"],
255
+ additionalProperties: false,
256
+ } as const;
257
+
258
+ export const STUDIO_ADD_KEYFRAME_DESCRIPTION = [
259
+ "Add a keyframe to an existing animation at a percentage through it.",
260
+ "All the properties land in one commit, so they are one undo entry.",
261
+ DISPATCH_CAVEAT,
262
+ ].join(" ");
263
+
264
+ export const STUDIO_DELETE_ANIMATION_INPUT_SCHEMA = {
265
+ type: "object",
266
+ properties: {
267
+ animationId: { type: "string", description: "An animation id from studio_inspect." },
268
+ },
269
+ required: ["animationId"],
270
+ additionalProperties: false,
271
+ } as const;
272
+
273
+ export const STUDIO_DELETE_ANIMATION_DESCRIPTION = [
274
+ "Remove an animation from the currently selected element. Undo reverses it.",
275
+ DISPATCH_CAVEAT,
276
+ ].join(" ");