@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
@@ -0,0 +1,275 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ studioSetStyle,
5
+ studioSetText,
6
+ type ContentToolDeps,
7
+ type StudioSetStyleResult,
8
+ type StudioSetTextResult,
9
+ } from "./contentTools";
10
+ import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
11
+
12
+ function contentDeps(overrides: Partial<ContentToolDeps> = {}): ContentToolDeps {
13
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
14
+ return {
15
+ getCurrentSelection: () => selectionFor(element),
16
+ getWriteBlockedReason: () => null,
17
+ setText: async () => ({ ok: true }),
18
+ setStyle: async () => ({ ok: true }),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("studioSetText", () => {
24
+ it("writes the text and reports what it now is", async () => {
25
+ const setText = vi.fn(async () => ({ ok: true }) as const);
26
+
27
+ const result = await studioSetText(contentDeps({ setText }), { text: "Ship it faster" });
28
+
29
+ const ok = expectOk<StudioSetTextResult>(result);
30
+ expect(ok.text).toBe("Ship it faster");
31
+ expect(ok.changed).toBe(true);
32
+ // The single field is resolved and named, rather than left undefined.
33
+ expect(setText).toHaveBeenCalledWith("Ship it faster", "self");
34
+ });
35
+
36
+ it("reports changed:false when the text already said that", async () => {
37
+ const result = await studioSetText(contentDeps(), { text: "Ship it" });
38
+
39
+ expect(expectOk<StudioSetTextResult>(result).changed).toBe(false);
40
+ });
41
+
42
+ it("refuses to write while a conflict is waiting for the user", async () => {
43
+ // The paused-save and conflict states are banners with no lock behind them.
44
+ // Nothing else stops a programmatic write landing on top of a decision the
45
+ // user has been asked to make.
46
+ const setText = vi.fn();
47
+
48
+ const result = expectFailure(
49
+ await studioSetText(
50
+ contentDeps({
51
+ getWriteBlockedReason: () => "an external change to this file is waiting to be resolved",
52
+ setText,
53
+ }),
54
+ { text: "Ship it faster" },
55
+ ),
56
+ );
57
+
58
+ expect(result.kind).toBe("blocked");
59
+ expect(result.reason).toMatch(/external change/);
60
+ expect(setText).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it("does not report success when the commit declined", async () => {
64
+ // The whole reason the handlers now return an outcome: they resolve on
65
+ // failure, so awaiting them proves nothing.
66
+ const result = expectFailure(
67
+ await studioSetText(
68
+ contentDeps({ setText: async () => ({ ok: false, reason: "persist-failed" }) }),
69
+ { text: "Ship it faster" },
70
+ ),
71
+ );
72
+
73
+ expect(result.kind).toBe("failed");
74
+ expect(result.reason).toMatch(/persist-failed/);
75
+ });
76
+
77
+ it("turns a decline reason into a hint naming what to do instead", async () => {
78
+ const result = expectFailure(
79
+ await studioSetText(
80
+ contentDeps({ setText: async () => ({ ok: false, reason: "not-text-editable" }) }),
81
+ { text: "x" },
82
+ ),
83
+ );
84
+
85
+ expect(result.kind).toBe("blocked");
86
+ expect(result.hint).toMatch(/studio_inspect/);
87
+ });
88
+
89
+ it("rejects a non-string text without dispatching", async () => {
90
+ const setText = vi.fn();
91
+
92
+ const result = expectFailure(await studioSetText(contentDeps({ setText }), { text: 42 }));
93
+
94
+ expect(result.kind).toBe("invalid");
95
+ expect(setText).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("fails when nothing is selected", async () => {
99
+ const setText = vi.fn();
100
+
101
+ const result = expectFailure(
102
+ await studioSetText(contentDeps({ getCurrentSelection: () => null, setText }), { text: "x" }),
103
+ );
104
+
105
+ expect(result.kind).toBe("invalid");
106
+ expect(result.hint).toMatch(/studio_select/);
107
+ expect(setText).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("targets the element's ACTUAL text field, not a field called self", async () => {
111
+ // Found end to end, not by these tests. An element's text usually lives in a
112
+ // child field keyed like `child:0:h1`. Passing no key planned zero
113
+ // operations, and the server rejected the empty patch with
114
+ // "target and operations required" -- a persist failure that looked like a
115
+ // server problem and was not.
116
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
117
+ const selection = selectionFor(element);
118
+ selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
119
+ const setText = vi.fn(async () => ({ ok: true }) as const);
120
+
121
+ await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
122
+ text: "Shipped it",
123
+ });
124
+
125
+ expect(setText).toHaveBeenCalledWith("Shipped it", "child:0:h1");
126
+ });
127
+
128
+ it("rejects a field the element does not have, rather than writing nowhere", async () => {
129
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
130
+ const selection = selectionFor(element);
131
+ selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
132
+ const setText = vi.fn();
133
+
134
+ const result = expectFailure(
135
+ await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
136
+ text: "x",
137
+ field: "self",
138
+ }),
139
+ );
140
+
141
+ expect(result.kind).toBe("invalid");
142
+ expect(result.hint).toContain("child:0:h1");
143
+ expect(setText).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("asks which field when the element has several", async () => {
147
+ const element = previewElement('<div id="card">a</div>', "card");
148
+ const selection = selectionFor(element);
149
+ const base = selection.textFields[0]!;
150
+ selection.textFields = [
151
+ { ...base, key: "child:0:h2" },
152
+ { ...base, key: "child:1:p" },
153
+ ];
154
+ const setText = vi.fn();
155
+
156
+ const result = expectFailure(
157
+ await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
158
+ text: "x",
159
+ }),
160
+ );
161
+
162
+ expect(result.kind).toBe("invalid");
163
+ expect(result.reason).toMatch(/2 text fields/);
164
+ expect(setText).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("reports an element with no text field as blocked", async () => {
168
+ const element = previewElement('<div id="box"></div>', "box");
169
+ const selection = selectionFor(element);
170
+ selection.textFields = [];
171
+ const setText = vi.fn();
172
+
173
+ const result = expectFailure(
174
+ await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
175
+ text: "x",
176
+ }),
177
+ );
178
+
179
+ expect(result.kind).toBe("blocked");
180
+ expect(setText).not.toHaveBeenCalled();
181
+ });
182
+ });
183
+
184
+ describe("studioSetStyle", () => {
185
+ it("applies every property and reports them", async () => {
186
+ const setStyle = vi.fn(async () => ({ ok: true }) as const);
187
+
188
+ const result = await studioSetStyle(contentDeps({ setStyle }), {
189
+ styles: { color: "red", "font-size": "48px" },
190
+ });
191
+
192
+ const ok = expectOk<StudioSetStyleResult>(result);
193
+ expect(ok.applied).toEqual({ color: "red", "font-size": "48px" });
194
+ expect(ok.rejected).toEqual({});
195
+ expect(setStyle).toHaveBeenCalledTimes(2);
196
+ });
197
+
198
+ it("commits sequentially, never concurrently", async () => {
199
+ // Two commits racing through Studio's client-side read-modify-write can
200
+ // record undo entries that both claim the same starting content.
201
+ let inFlight = 0;
202
+ let maxInFlight = 0;
203
+ const setStyle = vi.fn(async () => {
204
+ inFlight += 1;
205
+ maxInFlight = Math.max(maxInFlight, inFlight);
206
+ await Promise.resolve();
207
+ inFlight -= 1;
208
+ return { ok: true } as const;
209
+ });
210
+
211
+ await studioSetStyle(contentDeps({ setStyle }), {
212
+ styles: { color: "red", "font-size": "48px", opacity: "0.5" },
213
+ });
214
+
215
+ expect(maxInFlight).toBe(1);
216
+ });
217
+
218
+ it("reports a partial success as partial, not whole", async () => {
219
+ const setStyle = vi.fn(async (property: string) =>
220
+ property === "left"
221
+ ? ({ ok: false, reason: "geometry-property" } as const)
222
+ : ({ ok: true } as const),
223
+ );
224
+
225
+ const result = await studioSetStyle(contentDeps({ setStyle }), {
226
+ styles: { color: "red", left: "10px" },
227
+ });
228
+
229
+ const ok = expectOk<StudioSetStyleResult>(result);
230
+ expect(ok.applied).toEqual({ color: "red" });
231
+ expect(ok.rejected).toEqual({ left: "geometry-property" });
232
+ });
233
+
234
+ it("fails when every property was refused", async () => {
235
+ const result = expectFailure(
236
+ await studioSetStyle(
237
+ contentDeps({ setStyle: async () => ({ ok: false, reason: "styles-not-editable" }) }),
238
+ { styles: { color: "red" } },
239
+ ),
240
+ );
241
+
242
+ expect(result.kind).toBe("blocked");
243
+ expect(result.reason).toMatch(/styles-not-editable/);
244
+ });
245
+
246
+ it("rejects an empty styles object rather than committing nothing", async () => {
247
+ const setStyle = vi.fn();
248
+
249
+ const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), { styles: {} }));
250
+
251
+ expect(result.kind).toBe("invalid");
252
+ expect(setStyle).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it("rejects a non-object styles value", async () => {
256
+ for (const styles of ["color: red", 42, null, ["color"]]) {
257
+ const result = expectFailure(await studioSetStyle(contentDeps(), { styles }));
258
+ expect(result.kind).toBe("invalid");
259
+ }
260
+ });
261
+
262
+ it("refuses to write while a conflict is waiting for the user", async () => {
263
+ const setStyle = vi.fn();
264
+
265
+ const result = expectFailure(
266
+ await studioSetStyle(
267
+ contentDeps({ getWriteBlockedReason: () => "Auto-save is paused", setStyle }),
268
+ { styles: { color: "red" } },
269
+ ),
270
+ );
271
+
272
+ expect(result.kind).toBe("blocked");
273
+ expect(setStyle).not.toHaveBeenCalled();
274
+ });
275
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `studio_set_text` and `studio_set_style`: the first tools that change the file.
3
+ *
4
+ * Both operate on the CURRENT selection and take no handle. That is not an
5
+ * omission. `handleDomTextCommit(value, fieldKey?)` and
6
+ * `handleDomStyleCommit(property, value)` read the ambient React selection, and
7
+ * `applyDomSelection` only schedules a state update, so selecting and
8
+ * committing inside one call would write to whatever was selected before.
9
+ * Two tool calls are separated by a render. Select first, then edit.
10
+ *
11
+ * Every write here is guarded before dispatch and verified after. Studio has
12
+ * several paths where a failed commit resolves anyway, so "the function did not
13
+ * throw" proves nothing; the outcome the handler now returns is what proves it.
14
+ */
15
+
16
+ import type { DomEditCommitOutcome } from "../../hooks/domEditCommitRunner";
17
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
18
+ import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
19
+
20
+ export interface ContentToolDeps {
21
+ getCurrentSelection: () => DomEditSelection | null;
22
+ /** Why a write would be refused right now, or null. Checked BEFORE dispatch. */
23
+ getWriteBlockedReason: () => string | null;
24
+ setText: (value: string, fieldKey?: string) => Promise<DomEditCommitOutcome>;
25
+ setStyle: (property: string, value: string) => Promise<DomEditCommitOutcome>;
26
+ }
27
+
28
+ /**
29
+ * The reasons a commit declines, translated into something an agent can act on.
30
+ * `persist-failed` is exogenous; the rest are states it should route around.
31
+ */
32
+ const DECLINE_HINTS: Record<string, { kind: "blocked" | "invalid" | "failed"; hint?: string }> = {
33
+ "no-selection": { kind: "invalid", hint: "Call studio_select first." },
34
+ "no-project": { kind: "blocked" },
35
+ "geometry-property": {
36
+ kind: "blocked",
37
+ hint: "Position and size are not editable as styles. Use the transform tools.",
38
+ },
39
+ "styles-not-editable": {
40
+ kind: "blocked",
41
+ hint: "studio_inspect reports why, in can.reasonIfDisabled.",
42
+ },
43
+ "not-text-editable": {
44
+ kind: "blocked",
45
+ hint: "This element has no editable text. studio_inspect lists its textFields.",
46
+ },
47
+ "persist-failed": { kind: "failed", hint: "The write did not reach the file. Check Studio." },
48
+ };
49
+
50
+ function fromOutcome(outcome: DomEditCommitOutcome, what: string): ToolFailure | null {
51
+ if (outcome.ok) return null;
52
+ const mapped = DECLINE_HINTS[outcome.reason] ?? { kind: "failed" as const };
53
+ return toolFailure(mapped.kind, `${what} was not applied: ${outcome.reason}`, mapped.hint);
54
+ }
55
+
56
+ function guardWrite(deps: ContentToolDeps): ToolFailure | null {
57
+ // Both blocked states are banners in Studio's UI with no lock behind them, so
58
+ // nothing else stops a programmatic write from landing on top of a conflict
59
+ // the user has been asked to adjudicate.
60
+ const blocked = deps.getWriteBlockedReason();
61
+ if (blocked) {
62
+ return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
63
+ }
64
+ if (!deps.getCurrentSelection()) {
65
+ return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export interface StudioSetTextResult {
71
+ text: string;
72
+ changed: boolean;
73
+ }
74
+
75
+ export async function studioSetText(
76
+ deps: ContentToolDeps,
77
+ input: { text?: unknown; field?: unknown },
78
+ ): Promise<ToolResult<StudioSetTextResult>> {
79
+ if (typeof input.text !== "string") {
80
+ return toolFailure("invalid", "text must be a string");
81
+ }
82
+
83
+ const blocked = guardWrite(deps);
84
+ if (blocked) return blocked;
85
+
86
+ const selection = deps.getCurrentSelection();
87
+ if (!selection) return toolFailure("invalid", "nothing is selected");
88
+
89
+ const fields = selection.textFields;
90
+ const requested = typeof input.field === "string" && input.field ? input.field : undefined;
91
+ if (requested && !fields.some((candidate) => candidate.key === requested)) {
92
+ return toolFailure(
93
+ "invalid",
94
+ `this element has no text field "${requested}"`,
95
+ `Its fields are: ${fields.map((candidate) => candidate.key).join(", ") || "none"}.`,
96
+ );
97
+ }
98
+
99
+ // Resolving the field is NOT optional. An element's text usually lives in a
100
+ // child field keyed like `child:0:h1`, not in one called `self`, and passing
101
+ // no key plans zero operations. The server then rejects the empty patch with
102
+ // "target and operations required", which surfaces as a persist failure that
103
+ // looks like a server problem and is not.
104
+ const field = requested ?? (fields.length === 1 ? fields[0]?.key : undefined);
105
+ if (!field) {
106
+ if (fields.length === 0) {
107
+ return toolFailure(
108
+ "blocked",
109
+ "this element has no editable text field",
110
+ "studio_inspect lists an element's textFields.",
111
+ );
112
+ }
113
+ return toolFailure(
114
+ "invalid",
115
+ `this element has ${fields.length} text fields, so one must be named`,
116
+ `Pass field as one of: ${fields.map((candidate) => candidate.key).join(", ")}.`,
117
+ );
118
+ }
119
+
120
+ const before = selection.textContent ?? null;
121
+ const outcome = await deps.setText(input.text, field);
122
+ const failure = fromOutcome(outcome, "the text");
123
+ if (failure) return failure;
124
+
125
+ return toolOk<StudioSetTextResult>({ text: input.text, changed: before !== input.text });
126
+ }
127
+
128
+ export interface StudioSetStyleResult {
129
+ applied: Record<string, string>;
130
+ /** Properties the element refused, with the reason. Empty when all landed. */
131
+ rejected: Record<string, string>;
132
+ }
133
+
134
+ export async function studioSetStyle(
135
+ deps: ContentToolDeps,
136
+ input: { styles?: unknown },
137
+ ): Promise<ToolResult<StudioSetStyleResult>> {
138
+ const styles = input.styles;
139
+ if (typeof styles !== "object" || styles === null || Array.isArray(styles)) {
140
+ return toolFailure("invalid", "styles must be an object of CSS property to value");
141
+ }
142
+ const entries = Object.entries(styles).filter(
143
+ (entry): entry is [string, string] => typeof entry[1] === "string",
144
+ );
145
+ if (entries.length === 0) {
146
+ // An empty commit would report success having done nothing.
147
+ return toolFailure("invalid", "styles must contain at least one string value");
148
+ }
149
+
150
+ const blocked = guardWrite(deps);
151
+ if (blocked) return blocked;
152
+
153
+ // `handleDomStyleCommit` is one property per call, so N properties are N
154
+ // commits and N undo entries. Sequential, not concurrent: two commits racing
155
+ // through Studio's client-side read-modify-write can record undo entries that
156
+ // both claim the same starting content.
157
+ const applied: Record<string, string> = {};
158
+ const rejected: Record<string, string> = {};
159
+ for (const [property, value] of entries) {
160
+ const outcome = await deps.setStyle(property, value);
161
+ if (outcome.ok) applied[property] = value;
162
+ else rejected[property] = outcome.reason;
163
+ }
164
+
165
+ if (Object.keys(applied).length === 0) {
166
+ const reasons = Object.entries(rejected)
167
+ .map(([property, reason]) => `${property}: ${reason}`)
168
+ .join(", ");
169
+ return toolFailure("blocked", `no style was applied (${reasons})`);
170
+ }
171
+
172
+ return toolOk<StudioSetStyleResult>({ applied, rejected });
173
+ }
174
+
175
+ export const STUDIO_SET_TEXT_INPUT_SCHEMA = {
176
+ type: "object",
177
+ properties: {
178
+ text: { type: "string", description: "The new text content." },
179
+ field: {
180
+ type: "string",
181
+ description:
182
+ "Which text field to write, from studio_inspect. Omit for the element's own text.",
183
+ },
184
+ },
185
+ required: ["text"],
186
+ additionalProperties: false,
187
+ } as const;
188
+
189
+ export const STUDIO_SET_TEXT_DESCRIPTION = [
190
+ "Set the text of the CURRENTLY SELECTED element. Call studio_select first.",
191
+ "This is the edit a synthetic double-click cannot reach, because Studio's canvas",
192
+ "takes pointer capture and recognises the double press itself.",
193
+ "Returns `ok: true` with the resulting text and whether it changed, or `ok: false`",
194
+ "with `kind`, `reason` and usually a `hint` naming what to do instead.",
195
+ ].join(" ");
196
+
197
+ export const STUDIO_SET_STYLE_INPUT_SCHEMA = {
198
+ type: "object",
199
+ properties: {
200
+ styles: {
201
+ type: "object",
202
+ description: 'CSS property to value, for example {"color": "red", "font-size": "48px"}.',
203
+ additionalProperties: { type: "string" },
204
+ },
205
+ },
206
+ required: ["styles"],
207
+ additionalProperties: false,
208
+ } as const;
209
+
210
+ export const STUDIO_SET_STYLE_DESCRIPTION = [
211
+ "Set inline styles on the CURRENTLY SELECTED element. Call studio_select first.",
212
+ "Each property is a separate commit, so N properties produce N undo entries.",
213
+ "Position and size properties (left, top, width, height) are refused here on purpose;",
214
+ "they belong to the transform tools.",
215
+ "Returns `ok: true` with `applied` and `rejected` maps, so a partial success is visible",
216
+ "as a partial success rather than reported as a whole one.",
217
+ ].join(" ");
@@ -0,0 +1,136 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { studioFrame, type FrameToolDeps, type StudioFrameResult } from "./frameTools";
4
+ import { expectFailure, expectOk } from "../webmcpTestUtils";
5
+
6
+ function frameDeps(overrides: Partial<FrameToolDeps> = {}): FrameToolDeps {
7
+ return {
8
+ getProjectId: () => "demo",
9
+ getCompositionPath: () => "index.html",
10
+ readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }),
11
+ requestSeek: () => undefined,
12
+ probeFrame: async () => ({ ok: true, status: 200 }),
13
+ wait: async () => undefined,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe("studioFrame", () => {
19
+ it("returns a URL for the composition at the playhead", async () => {
20
+ const result = await studioFrame(frameDeps());
21
+
22
+ const ok = expectOk<StudioFrameResult>(result);
23
+ expect(ok.time).toBe(2.4);
24
+ expect(ok.compositionPath).toBe("index.html");
25
+ expect(ok.url).toContain("/thumbnail/");
26
+ expect(ok.url).toContain("t=2.400");
27
+ expect(ok.url).toContain("format=png");
28
+ });
29
+
30
+ it("seeks first when given a time", async () => {
31
+ const requestSeek = vi.fn();
32
+
33
+ await studioFrame(frameDeps({ requestSeek }), { time: 5 });
34
+
35
+ expect(requestSeek).toHaveBeenCalledWith(5);
36
+ });
37
+
38
+ it("captures where the playhead LANDED, not what was asked for", async () => {
39
+ // The player clamps. Reporting the request would attach the wrong time to
40
+ // the frame, and an agent judging motion would draw the wrong conclusion.
41
+ const result = await studioFrame(
42
+ frameDeps({ readPlayhead: () => ({ currentTime: 10, duration: 10, isPlaying: false }) }),
43
+ { time: 999 },
44
+ );
45
+
46
+ const ok = expectOk<StudioFrameResult>(result);
47
+ expect(ok.time).toBe(10);
48
+ expect(ok.url).toContain("t=10.000");
49
+ });
50
+
51
+ it("waits before capturing, so a just-made edit is in the frame", async () => {
52
+ // The render cache is cleared by a file watcher with a write-stability
53
+ // threshold. Capturing faster than that renders the PRE-edit composition.
54
+ const wait = vi.fn(async () => undefined);
55
+ const order: string[] = [];
56
+
57
+ await studioFrame(
58
+ frameDeps({
59
+ wait: async (ms) => {
60
+ order.push(`wait:${ms}`);
61
+ await wait();
62
+ },
63
+ probeFrame: async () => {
64
+ order.push("probe");
65
+ return { ok: true, status: 200 };
66
+ },
67
+ }),
68
+ );
69
+
70
+ expect(order).toEqual(["wait:150", "probe"]);
71
+ });
72
+
73
+ it("honours a caller-supplied settle time and reports it", async () => {
74
+ const result = await studioFrame(frameDeps(), { settleMs: 800 });
75
+
76
+ expect(expectOk<StudioFrameResult>(result).settledMs).toBe(800);
77
+ });
78
+
79
+ it("clamps an absurd settle time rather than hanging", async () => {
80
+ const result = await studioFrame(frameDeps(), { settleMs: 10 * 60 * 1000 });
81
+
82
+ expect(expectOk<StudioFrameResult>(result).settledMs).toBe(5000);
83
+ });
84
+
85
+ it("falls back to the default for a nonsense settle time", async () => {
86
+ for (const settleMs of [-1, Number.NaN]) {
87
+ const result = await studioFrame(frameDeps(), { settleMs });
88
+ expect(expectOk<StudioFrameResult>(result).settledMs).toBe(150);
89
+ }
90
+ });
91
+
92
+ it("skips the wait entirely when asked for zero", async () => {
93
+ const wait = vi.fn(async () => undefined);
94
+
95
+ await studioFrame(frameDeps({ wait }), { settleMs: 0 });
96
+
97
+ expect(wait).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("reports a renderer failure instead of handing back a dead URL", async () => {
101
+ const result = expectFailure(
102
+ await studioFrame(frameDeps({ probeFrame: async () => ({ ok: false, status: 500 }) })),
103
+ );
104
+
105
+ expect(result.kind).toBe("failed");
106
+ expect(result.reason).toContain("500");
107
+ expect(result.hint).toBeDefined();
108
+ });
109
+
110
+ it("fails when no project is open, before touching the renderer", async () => {
111
+ const probeFrame = vi.fn();
112
+
113
+ const result = expectFailure(
114
+ await studioFrame(frameDeps({ getProjectId: () => null, probeFrame })),
115
+ );
116
+
117
+ expect(result.kind).toBe("blocked");
118
+ expect(probeFrame).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("rejects a negative or non-finite time without seeking", async () => {
122
+ const requestSeek = vi.fn();
123
+
124
+ for (const time of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
125
+ const result = expectFailure(await studioFrame(frameDeps({ requestSeek }), { time }));
126
+ expect(result.kind).toBe("invalid");
127
+ }
128
+ expect(requestSeek).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("captures the master composition when no path is active", async () => {
132
+ const result = await studioFrame(frameDeps({ getCompositionPath: () => null }));
133
+
134
+ expect(expectOk<StudioFrameResult>(result).compositionPath).toBe("index.html");
135
+ });
136
+ });