@hyperframes/studio 0.8.19 → 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.
- package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
- package/dist/assets/{index-Dd0fsIIL.js → index-44gfDvkh.js} +1 -1
- package/dist/assets/{index-DxccOrkZ.js → index-9VfE7bZe.js} +1 -1
- package/dist/assets/{index-Bjd2hioj.js → index-CgGY8dAv.js} +164 -164
- package/dist/index.d.ts +28 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1206 -234
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/contexts/StudioContext.tsx +10 -0
- package/src/hooks/useEditorSave.test.tsx +81 -3
- package/src/hooks/useEditorSave.ts +31 -6
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
- package/src/hooks/useStudioContextValue.ts +9 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
- package/src/player/hooks/timelineSyncHydration.ts +47 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
- package/src/player/store/playerStore.ts +17 -2
- package/src/player/store/timelineElement.ts +21 -0
- package/src/utils/studioFileVersion.test.ts +37 -1
- package/src/utils/studioFileVersion.ts +12 -2
- package/src/utils/studioSaveDiagnostics.test.ts +29 -0
- package/src/utils/studioSaveDiagnostics.ts +4 -0
- package/src/webmcp/StudioAgentTools.tsx +105 -10
- package/src/webmcp/toolResult.ts +1 -1
- package/src/webmcp/tools/animationTools.test.ts +244 -0
- package/src/webmcp/tools/animationTools.ts +276 -0
- package/src/webmcp/tools/contentTools.test.ts +275 -0
- package/src/webmcp/tools/contentTools.ts +217 -0
- package/src/webmcp/tools/frameTools.test.ts +136 -0
- package/src/webmcp/tools/frameTools.ts +133 -0
- package/src/webmcp/tools/inspectTools.test.ts +197 -0
- package/src/webmcp/tools/inspectTools.ts +208 -0
- package/src/webmcp/tools/selectionTools.test.ts +164 -0
- package/src/webmcp/tools/selectionTools.ts +154 -0
- package/src/webmcp/tools/transformTools.test.ts +179 -0
- package/src/webmcp/tools/transformTools.ts +205 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
- package/src/webmcp/useStudioAgentTools.ts +195 -1
- package/src/webmcp/webmcpTestUtils.ts +91 -0
- package/dist/assets/hyperframes-player-BA-QO9CQ.js +0 -459
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.21",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/parsers": "0.8.
|
|
53
|
-
"@hyperframes/player": "0.8.
|
|
54
|
-
"@hyperframes/sdk": "0.8.
|
|
55
|
-
"@hyperframes/studio-server": "0.8.
|
|
51
|
+
"@hyperframes/core": "0.8.21",
|
|
52
|
+
"@hyperframes/parsers": "0.8.21",
|
|
53
|
+
"@hyperframes/player": "0.8.21",
|
|
54
|
+
"@hyperframes/sdk": "0.8.21",
|
|
55
|
+
"@hyperframes/studio-server": "0.8.21"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^3.2.4",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.21"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -433,6 +433,8 @@ export function StudioApp() {
|
|
|
433
433
|
handleRedo: appHotkeys.handleRedo,
|
|
434
434
|
renderQueue,
|
|
435
435
|
compositionDimensions,
|
|
436
|
+
domEditSaveQueuePaused: previewPersistence.domEditSaveQueuePaused,
|
|
437
|
+
externalFileConflict: externalFileChanges.blocked !== null,
|
|
436
438
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
437
439
|
handlePreviewIframeRef,
|
|
438
440
|
refreshPreviewDocumentVersion,
|
|
@@ -16,6 +16,13 @@ export interface StudioShellValue {
|
|
|
16
16
|
undoLabel: string | undefined;
|
|
17
17
|
redoLabel: string | undefined;
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Why a composition write would be refused right now, or null when writes
|
|
21
|
+
* are possible. Derived from the paused save queue and the external-file
|
|
22
|
+
* conflict state, both of which are otherwise banners with no lock behind
|
|
23
|
+
* them. One field rather than two, so there is one owner of the question.
|
|
24
|
+
*/
|
|
25
|
+
writeBlockedReason: string | null;
|
|
19
26
|
handleUndo: () => Promise<void>;
|
|
20
27
|
handleRedo: () => Promise<void>;
|
|
21
28
|
renderQueue: {
|
|
@@ -106,6 +113,7 @@ export function StudioShellProvider({
|
|
|
106
113
|
showToast,
|
|
107
114
|
previewIframeRef,
|
|
108
115
|
editHistory,
|
|
116
|
+
writeBlockedReason,
|
|
109
117
|
handleUndo,
|
|
110
118
|
handleRedo,
|
|
111
119
|
renderQueue,
|
|
@@ -122,6 +130,7 @@ export function StudioShellProvider({
|
|
|
122
130
|
showToast,
|
|
123
131
|
previewIframeRef,
|
|
124
132
|
editHistory,
|
|
133
|
+
writeBlockedReason,
|
|
125
134
|
handleUndo,
|
|
126
135
|
handleRedo,
|
|
127
136
|
renderQueue,
|
|
@@ -138,6 +147,7 @@ export function StudioShellProvider({
|
|
|
138
147
|
setActiveCompPath,
|
|
139
148
|
showToast,
|
|
140
149
|
previewIframeRef,
|
|
150
|
+
writeBlockedReason,
|
|
141
151
|
handleUndo,
|
|
142
152
|
handleRedo,
|
|
143
153
|
waitForPendingDomEditSaves,
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
|
|
9
|
+
trackStudioSaveFailure,
|
|
10
|
+
}));
|
|
11
|
+
|
|
6
12
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
13
|
+
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
|
|
7
14
|
|
|
8
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
16
|
|
|
@@ -11,6 +18,7 @@ type WriteProjectFile = (path: string, content: string, expectedContent?: string
|
|
|
11
18
|
|
|
12
19
|
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
13
20
|
const captured: { handle: EditorSaveHandle | null } = { handle: null };
|
|
21
|
+
const showToast = vi.fn();
|
|
14
22
|
|
|
15
23
|
function Probe() {
|
|
16
24
|
captured.handle = useEditorSave({
|
|
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
|
21
29
|
recordEdit: vi.fn(async () => undefined),
|
|
22
30
|
domEditSaveTimestampRef: { current: 0 },
|
|
23
31
|
setRefreshKey: vi.fn(),
|
|
24
|
-
showToast
|
|
32
|
+
showToast,
|
|
25
33
|
});
|
|
26
34
|
return null;
|
|
27
35
|
}
|
|
@@ -32,12 +40,14 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
|
32
40
|
|
|
33
41
|
return {
|
|
34
42
|
handle: captured.handle,
|
|
43
|
+
showToast,
|
|
35
44
|
unmount: () => act(async () => root.unmount()),
|
|
36
45
|
};
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
describe("useEditorSave pending work", () => {
|
|
40
49
|
beforeEach(() => {
|
|
50
|
+
trackStudioSaveFailure.mockClear();
|
|
41
51
|
vi.stubGlobal(
|
|
42
52
|
"requestAnimationFrame",
|
|
43
53
|
vi.fn(() => 41),
|
|
@@ -45,7 +55,10 @@ describe("useEditorSave pending work", () => {
|
|
|
45
55
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
46
56
|
});
|
|
47
57
|
|
|
48
|
-
afterEach(() =>
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
});
|
|
49
62
|
|
|
50
63
|
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
|
|
51
64
|
const writeProjectFile = vi.fn(async () => undefined);
|
|
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
|
|
|
111
124
|
status: "conflict",
|
|
112
125
|
error: conflict,
|
|
113
126
|
});
|
|
127
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith({
|
|
128
|
+
source: "code_editor",
|
|
129
|
+
error: conflict,
|
|
130
|
+
filePath: "index.html",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await mounted.unmount();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("emits one identical failure per five-second burst", async () => {
|
|
137
|
+
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
138
|
+
const error = new Error("Load failed");
|
|
139
|
+
const mounted = await mountEditorSave(async () => {
|
|
140
|
+
throw error;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
144
|
+
await mounted.handle.flushPendingSave();
|
|
145
|
+
vi.spyOn(Date, "now").mockReturnValue(2_000);
|
|
146
|
+
act(() => mounted.handle.handleContentChange("second candidate"));
|
|
147
|
+
await mounted.handle.flushPendingSave();
|
|
148
|
+
|
|
149
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
|
|
150
|
+
expect(mounted.showToast).toHaveBeenCalledOnce();
|
|
151
|
+
await mounted.unmount();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("emits a changed failure immediately and repeats after the burst window", async () => {
|
|
155
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
156
|
+
const writeProjectFile = vi
|
|
157
|
+
.fn<WriteProjectFile>()
|
|
158
|
+
.mockRejectedValueOnce(new Error("Load failed"))
|
|
159
|
+
.mockRejectedValueOnce(new Error("Failed to fetch"))
|
|
160
|
+
.mockRejectedValueOnce(new Error("Failed to fetch"));
|
|
161
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
162
|
+
|
|
163
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
164
|
+
await mounted.handle.flushPendingSave();
|
|
165
|
+
now.mockReturnValue(2_000);
|
|
166
|
+
act(() => mounted.handle.handleContentChange("second candidate"));
|
|
167
|
+
await mounted.handle.flushPendingSave();
|
|
168
|
+
now.mockReturnValue(8_000);
|
|
169
|
+
act(() => mounted.handle.handleContentChange("third candidate"));
|
|
170
|
+
await mounted.handle.flushPendingSave();
|
|
171
|
+
|
|
172
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
|
|
173
|
+
await mounted.unmount();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("emits the same failure again after a successful save", async () => {
|
|
177
|
+
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
178
|
+
const writeProjectFile = vi
|
|
179
|
+
.fn<WriteProjectFile>()
|
|
180
|
+
.mockRejectedValueOnce(new Error("Load failed"))
|
|
181
|
+
.mockResolvedValueOnce(undefined)
|
|
182
|
+
.mockRejectedValueOnce(new Error("Load failed"));
|
|
183
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
184
|
+
|
|
185
|
+
act(() => mounted.handle.handleContentChange("first candidate"));
|
|
186
|
+
await mounted.handle.flushPendingSave();
|
|
187
|
+
act(() => mounted.handle.handleContentChange("successful candidate"));
|
|
188
|
+
await mounted.handle.flushPendingSave();
|
|
189
|
+
act(() => mounted.handle.handleContentChange("third candidate"));
|
|
190
|
+
await mounted.handle.flushPendingSave();
|
|
114
191
|
|
|
192
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
|
|
115
193
|
await mounted.unmount();
|
|
116
194
|
});
|
|
117
195
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
3
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
|
-
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
4
|
import {
|
|
6
5
|
StudioFileConflictError,
|
|
6
|
+
buildStudioSaveFailureProperties,
|
|
7
|
+
trackStudioSaveFailure,
|
|
7
8
|
type StudioSaveDrainResult,
|
|
8
9
|
} from "../utils/studioSaveDiagnostics";
|
|
9
10
|
|
|
11
|
+
const FAILURE_BURST_MS = 5_000;
|
|
12
|
+
|
|
10
13
|
interface RecordEditInput {
|
|
11
14
|
label: string;
|
|
12
15
|
kind: EditHistoryKind;
|
|
@@ -58,19 +61,40 @@ export function useEditorSave({
|
|
|
58
61
|
const refreshRafRef = useRef<number | null>(null);
|
|
59
62
|
// One error toast per burst of failures — every keystroke retries the save,
|
|
60
63
|
// and error toasts persist until dismissed, so don't stack duplicates.
|
|
61
|
-
const lastFailureToastAtRef = useRef(
|
|
64
|
+
const lastFailureToastAtRef = useRef<number | null>(null);
|
|
65
|
+
const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
|
|
62
66
|
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
63
67
|
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
|
|
64
68
|
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
65
69
|
|
|
66
70
|
const reportFailure = useCallback(
|
|
67
71
|
(path: string, error: unknown) => {
|
|
68
|
-
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const properties = buildStudioSaveFailureProperties({
|
|
69
74
|
source: "code_editor",
|
|
70
|
-
|
|
75
|
+
error,
|
|
76
|
+
filePath: path,
|
|
71
77
|
});
|
|
72
|
-
const
|
|
73
|
-
|
|
78
|
+
const errorName = error instanceof Error ? error.name : typeof error;
|
|
79
|
+
const fingerprint = JSON.stringify([
|
|
80
|
+
path,
|
|
81
|
+
errorName,
|
|
82
|
+
properties.error_message,
|
|
83
|
+
properties.status_code,
|
|
84
|
+
]);
|
|
85
|
+
const previous = lastFailureReportRef.current;
|
|
86
|
+
if (
|
|
87
|
+
previous === null ||
|
|
88
|
+
previous.fingerprint !== fingerprint ||
|
|
89
|
+
now - previous.emittedAt >= FAILURE_BURST_MS
|
|
90
|
+
) {
|
|
91
|
+
trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
|
|
92
|
+
lastFailureReportRef.current = { fingerprint, emittedAt: now };
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
lastFailureToastAtRef.current === null ||
|
|
96
|
+
now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
|
|
97
|
+
) {
|
|
74
98
|
lastFailureToastAtRef.current = now;
|
|
75
99
|
showToast(
|
|
76
100
|
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
@@ -95,6 +119,7 @@ export function useEditorSave({
|
|
|
95
119
|
})
|
|
96
120
|
.then<EditorSaveDrainResult>(() => {
|
|
97
121
|
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
|
|
122
|
+
lastFailureReportRef.current = null;
|
|
98
123
|
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
99
124
|
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
100
125
|
return { status: "clean" };
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
7
|
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
8
8
|
|
|
9
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
10
|
|
|
11
|
-
const trackStudioSaveFailure = vi.hoisted(() =>
|
|
12
|
-
vi.
|
|
11
|
+
const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
|
|
12
|
+
trackStudioEditBlocked: vi.fn(),
|
|
13
|
+
trackStudioSaveFailure: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("../utils/studioSaveDiagnostics", () => ({
|
|
16
|
+
trackStudioEditBlocked,
|
|
17
|
+
trackStudioSaveFailure,
|
|
18
|
+
}));
|
|
13
19
|
|
|
14
20
|
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
15
21
|
|
|
22
|
+
const selection = {
|
|
23
|
+
id: "clip",
|
|
24
|
+
selector: "#clip",
|
|
25
|
+
element: document.createElement("div"),
|
|
26
|
+
} as unknown as DomEditSelection;
|
|
27
|
+
|
|
28
|
+
function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
|
|
29
|
+
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
30
|
+
function Harness() {
|
|
31
|
+
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const root = mountReactHarness(<Harness />);
|
|
35
|
+
return { report, root };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
describe("useGsapInteractionFailureTelemetry", () => {
|
|
17
|
-
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
trackStudioEditBlocked.mockClear();
|
|
41
|
+
trackStudioSaveFailure.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("tracks an expected edit block separately from save failures", () => {
|
|
18
45
|
const showToast = vi.fn();
|
|
19
|
-
const
|
|
20
|
-
id: "clip",
|
|
21
|
-
selector: "#clip",
|
|
22
|
-
element: document.createElement("div"),
|
|
23
|
-
} as unknown as DomEditSelection;
|
|
24
|
-
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
|
|
25
|
-
function Harness() {
|
|
26
|
-
report = useGsapInteractionFailureTelemetry("index.html", showToast);
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
const root = mountReactHarness(<Harness />);
|
|
46
|
+
const { report, root } = mountFailureTelemetry(showToast);
|
|
30
47
|
|
|
31
48
|
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
|
|
32
49
|
|
|
@@ -34,9 +51,24 @@ describe("useGsapInteractionFailureTelemetry", () => {
|
|
|
34
51
|
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
35
52
|
"error",
|
|
36
53
|
);
|
|
37
|
-
expect(
|
|
54
|
+
expect(trackStudioEditBlocked).toHaveBeenCalledWith(
|
|
38
55
|
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
|
|
39
56
|
);
|
|
57
|
+
expect(trackStudioSaveFailure).not.toHaveBeenCalled();
|
|
58
|
+
act(() => root.unmount());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps unexpected GSAP persistence errors in save_failure", () => {
|
|
62
|
+
const showToast = vi.fn();
|
|
63
|
+
const { report, root } = mountFailureTelemetry(showToast);
|
|
64
|
+
const error = new Error("network dropped");
|
|
65
|
+
|
|
66
|
+
act(() => report(error, selection, "drag", "Move"));
|
|
67
|
+
|
|
68
|
+
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
|
|
69
|
+
expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
|
|
70
|
+
);
|
|
71
|
+
expect(trackStudioEditBlocked).not.toHaveBeenCalled();
|
|
40
72
|
act(() => root.unmount());
|
|
41
73
|
});
|
|
42
74
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
-
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
3
|
+
import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
4
|
import { isGsapEditBlockedError } from "./gsapEditOutcome";
|
|
5
5
|
|
|
6
6
|
export function useGsapInteractionFailureTelemetry(
|
|
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
|
|
|
9
9
|
) {
|
|
10
10
|
return useCallback(
|
|
11
11
|
(error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
|
|
12
|
-
|
|
12
|
+
const report = isGsapEditBlockedError(error)
|
|
13
|
+
? trackStudioEditBlocked
|
|
14
|
+
: trackStudioSaveFailure;
|
|
15
|
+
report({
|
|
13
16
|
source: "gsap_commit",
|
|
14
17
|
error,
|
|
15
18
|
filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
|
|
@@ -25,6 +25,10 @@ interface StudioContextInput {
|
|
|
25
25
|
// fields around it: the context type owns it.
|
|
26
26
|
renderQueue: StudioContextValue["renderQueue"];
|
|
27
27
|
compositionDimensions: { width: number; height: number } | null;
|
|
28
|
+
/** Message from `usePreviewPersistence` when auto-save is paused. */
|
|
29
|
+
domEditSaveQueuePaused: string | null;
|
|
30
|
+
/** True when an external edit to the open file is awaiting the user's decision. */
|
|
31
|
+
externalFileConflict: boolean;
|
|
28
32
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
29
33
|
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
|
|
30
34
|
refreshPreviewDocumentVersion: () => void;
|
|
@@ -46,6 +50,11 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
|
|
46
50
|
timelineElements: input.timelineElements,
|
|
47
51
|
isPlaying: input.isPlaying,
|
|
48
52
|
editHistory: input.editHistory,
|
|
53
|
+
// Conflict first: when both are true the conflict is the one the user has
|
|
54
|
+
// been asked to decide, and resolving it is what unblocks the queue.
|
|
55
|
+
writeBlockedReason: input.externalFileConflict
|
|
56
|
+
? "an external change to this file is waiting to be resolved"
|
|
57
|
+
: input.domEditSaveQueuePaused,
|
|
49
58
|
handleUndo: input.handleUndo,
|
|
50
59
|
handleRedo: input.handleRedo,
|
|
51
60
|
renderQueue: input.renderQueue,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
collectSubCompositionDomChildren,
|
|
6
|
+
collectSubCompositionHostState,
|
|
7
|
+
} from "./timelineSyncHydration";
|
|
8
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
9
|
+
|
|
10
|
+
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
|
11
|
+
id: "x",
|
|
12
|
+
label: "x",
|
|
13
|
+
start: 0,
|
|
14
|
+
duration: 1,
|
|
15
|
+
track: 0,
|
|
16
|
+
kind: "element",
|
|
17
|
+
tagName: "div",
|
|
18
|
+
compositionId: null,
|
|
19
|
+
parentCompositionId: null,
|
|
20
|
+
compositionSrc: null,
|
|
21
|
+
assetUrl: null,
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The shape a storyboard scene actually publishes: the clip that carries
|
|
27
|
+
* `data-hidden` is a `<video>` one level BELOW an id'd region wrapper.
|
|
28
|
+
*/
|
|
29
|
+
function mountScene(): Document {
|
|
30
|
+
document.body.innerHTML = `
|
|
31
|
+
<div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
|
|
32
|
+
<div data-hf-inner-root>
|
|
33
|
+
<div id="scene-2-video-region" class="hf-region">
|
|
34
|
+
<video id="scene-2-video" class="clip" data-start="0" data-hidden></video>
|
|
35
|
+
</div>
|
|
36
|
+
<div id="scene-2-title" class="clip" data-start="0" data-hidden></div>
|
|
37
|
+
<div id="scene-2-caption" class="clip" data-start="0" data-timeline-locked
|
|
38
|
+
data-timeline-role="caption" data-fx-chain="blur" data-automation="opacity"></div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>`;
|
|
41
|
+
return document;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
|
|
45
|
+
// (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
|
|
46
|
+
// the host with getElementById(clip.id) exactly as the sibling walk does.
|
|
47
|
+
const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
|
|
48
|
+
|
|
49
|
+
describe("collectSubCompositionHostState", () => {
|
|
50
|
+
it("reaches a clip nested below an id'd wrapper, which the sibling walk cannot", () => {
|
|
51
|
+
const doc = mountScene();
|
|
52
|
+
|
|
53
|
+
// The walk that defines rows stops at the first id'd descendant, so the
|
|
54
|
+
// video inside the region wrapper is never recorded there. That is why the
|
|
55
|
+
// eye on a hidden scene video had no state to read.
|
|
56
|
+
const siblings = collectSubCompositionDomChildren(doc, sceneClips, new Map());
|
|
57
|
+
expect(siblings.map((child) => child.id)).toEqual([
|
|
58
|
+
"scene-2-video-region",
|
|
59
|
+
"scene-2-title",
|
|
60
|
+
"scene-2-caption",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const state = collectSubCompositionHostState(doc, sceneClips);
|
|
64
|
+
expect(state.get("scene-2-video")?.hidden).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("records every data-* attribute an expanded child row needs", () => {
|
|
68
|
+
const state = collectSubCompositionHostState(mountScene(), sceneClips);
|
|
69
|
+
|
|
70
|
+
expect(state.get("scene-2-title")).toEqual({ hidden: true });
|
|
71
|
+
expect(state.get("scene-2-caption")).toEqual({
|
|
72
|
+
timelineLocked: true,
|
|
73
|
+
timelineRole: "caption",
|
|
74
|
+
fxChain: "blur",
|
|
75
|
+
automation: "opacity",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("omits elements carrying no state, so a visible child reads as visible", () => {
|
|
80
|
+
const state = collectSubCompositionHostState(mountScene(), sceneClips);
|
|
81
|
+
|
|
82
|
+
expect(state.has("scene-2-video-region")).toBe(false);
|
|
83
|
+
expect(state.get("scene-2-video")?.hidden).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns empty without a document, rather than throwing", () => {
|
|
87
|
+
expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("ignores clips that are not compositions", () => {
|
|
91
|
+
const doc = mountScene();
|
|
92
|
+
const state = collectSubCompositionHostState(doc, [clip({ id: "scene-2-slot" })]);
|
|
93
|
+
|
|
94
|
+
expect(state.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { usePlayerStore } from "../store/playerStore";
|
|
13
|
-
import type { TimelineElement, DomClipChild } from "../store/playerStore";
|
|
13
|
+
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
|
|
14
14
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
15
15
|
import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
|
|
16
16
|
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
|
|
@@ -137,6 +137,52 @@ export function collectSubCompositionDomChildren(
|
|
|
137
137
|
return out;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** The host-element `data-*` state one element carries, or null when it has none. */
|
|
141
|
+
function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
|
|
142
|
+
const state: SubCompositionHostState = {};
|
|
143
|
+
if (el.hasAttribute("data-hidden")) state.hidden = true;
|
|
144
|
+
if (el.hasAttribute("data-timeline-locked")) state.timelineLocked = true;
|
|
145
|
+
const timelineRole = el.getAttribute("data-timeline-role");
|
|
146
|
+
if (timelineRole) state.timelineRole = timelineRole;
|
|
147
|
+
const fxChain = el.getAttribute("data-fx-chain");
|
|
148
|
+
if (fxChain) state.fxChain = fxChain;
|
|
149
|
+
const automation = el.getAttribute("data-automation");
|
|
150
|
+
if (automation) state.automation = automation;
|
|
151
|
+
return Object.keys(state).length > 0 ? state : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Host-element state for every id'd element inside every sub-composition.
|
|
156
|
+
*
|
|
157
|
+
* This walk exists because nothing else in the pipeline can see these
|
|
158
|
+
* attributes. A clip whose parent composition is itself in the manifest is
|
|
159
|
+
* filtered out before the flat store is built, so an expanded child has no twin
|
|
160
|
+
* to inherit from, and the manifest carries timing rather than attributes.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately separate from {@link collectSubCompositionDomChildren}: that walk
|
|
163
|
+
* defines which rows exist and writes `parentMap`, and it stops at the first
|
|
164
|
+
* id'd descendant. Scene footage commonly sits one level below an id'd region
|
|
165
|
+
* wrapper, so it is never reached there. This one descends the whole subtree and
|
|
166
|
+
* touches neither rows nor parentage.
|
|
167
|
+
*/
|
|
168
|
+
export function collectSubCompositionHostState(
|
|
169
|
+
iframeDoc: Document | null,
|
|
170
|
+
clips: readonly ClipManifestClip[],
|
|
171
|
+
): Map<string, SubCompositionHostState> {
|
|
172
|
+
const out = new Map<string, SubCompositionHostState>();
|
|
173
|
+
if (!iframeDoc) return out;
|
|
174
|
+
for (const clip of clips) {
|
|
175
|
+
if (clip.kind !== "composition" || !clip.id) continue;
|
|
176
|
+
const hostEl = iframeDoc.getElementById(clip.id);
|
|
177
|
+
if (!hostEl) continue;
|
|
178
|
+
for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
|
|
179
|
+
const state = readSubCompositionHostState(el);
|
|
180
|
+
if (state) out.set(el.id, state);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
/** An iframe's document, or null when reading it throws (cross-origin, or the
|
|
141
187
|
* frame is mid-navigation). */
|
|
142
188
|
export function safeContentDocument(iframe: HTMLIFrameElement | null): Document | null {
|
|
@@ -270,6 +270,49 @@ describe("buildExpandedElements", () => {
|
|
|
270
270
|
expect(child.timelineLocked).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
// The test above only covers a host with no compositionSrc, where the child's
|
|
274
|
+
// key falls back to the `index.html` scope and a flat store twin can exist. A
|
|
275
|
+
// REAL sub-composition scopes the child key to the sub-comp file, and clips
|
|
276
|
+
// whose parent composition is itself in the manifest are filtered out of the
|
|
277
|
+
// flat store before it is built (`processTimelineMessage`). So the twin never
|
|
278
|
+
// exists there and the inheritance above is dead code for the one case it was
|
|
279
|
+
// written for: the eye reported every hidden child visible, clicking it
|
|
280
|
+
// rewrote data-hidden, and nothing could be shown again.
|
|
281
|
+
it("reads hidden and locked off the live element when the child has no flat twin", () => {
|
|
282
|
+
// Only the host is in the flat store — exactly what the manifest filter leaves.
|
|
283
|
+
const elements = [
|
|
284
|
+
el({
|
|
285
|
+
id: "scene-2",
|
|
286
|
+
domId: "scene-2",
|
|
287
|
+
start: 3.25,
|
|
288
|
+
duration: 3.5,
|
|
289
|
+
compositionSrc: "scene-2.html",
|
|
290
|
+
}),
|
|
291
|
+
];
|
|
292
|
+
const manifest = [
|
|
293
|
+
clip({ id: "scene-2", start: 3.25, duration: 3.5, compositionSrc: "scene-2.html" }),
|
|
294
|
+
clip({ id: "scene-2-video", start: 3.25, duration: 3.5, parentCompositionId: "scene-2" }),
|
|
295
|
+
];
|
|
296
|
+
const parentMap = new Map([["scene-2-video", "scene-2"]]);
|
|
297
|
+
const hostState = new Map([["scene-2-video", { hidden: true, timelineLocked: true }]]);
|
|
298
|
+
|
|
299
|
+
const out = buildExpandedElements(
|
|
300
|
+
elements,
|
|
301
|
+
manifest,
|
|
302
|
+
parentMap,
|
|
303
|
+
"scene-2",
|
|
304
|
+
"scene-2",
|
|
305
|
+
[],
|
|
306
|
+
hostState,
|
|
307
|
+
);
|
|
308
|
+
const child = out.find((e) => e.domId === "scene-2-video")!;
|
|
309
|
+
// The child key is scoped to the sub-comp file, so no store element can match it.
|
|
310
|
+
expect(child.key).toBe("scene-2.html#scene-2-video");
|
|
311
|
+
expect(elements.some((element) => element.key === child.key)).toBe(false);
|
|
312
|
+
expect(child.hidden).toBe(true);
|
|
313
|
+
expect(child.timelineLocked).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
273
316
|
// Sub-comp internals (group + pills) have no data-start, so they're not in the
|
|
274
317
|
// manifest. They arrive as DOM children and must still expand under their host.
|
|
275
318
|
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
|