@hyperframes/studio 0.8.46 → 0.8.48
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-CtVX6jL_.js +464 -0
- package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/index-C9gJKpqQ.js +441 -0
- package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
- package/dist/index.d.ts +83 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1118 -661
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
- package/src/hooks/useTimelineAssetDropOps.ts +20 -16
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +25 -28
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +93 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
- package/dist/assets/index-DCblmTX3.js +0 -441
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -5,13 +5,24 @@ import {
|
|
|
5
5
|
saveProjectFilesWithHistory,
|
|
6
6
|
type DomEditCommitBaseParams,
|
|
7
7
|
} from "../utils/studioFileHistory";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildDomEditPatchTarget,
|
|
10
|
+
readHfId,
|
|
11
|
+
type DomEditSelection,
|
|
12
|
+
} from "../components/editor/domEditing";
|
|
9
13
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
14
|
+
import {
|
|
15
|
+
findMatchingTimelineElementId,
|
|
16
|
+
resolveElementTrack,
|
|
17
|
+
type ElementMatchSelection,
|
|
18
|
+
} from "../utils/studioHelpers";
|
|
19
|
+
import type { TimelineElement } from "../player";
|
|
10
20
|
|
|
11
21
|
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
|
|
12
22
|
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
|
13
23
|
* structure the in-memory doc doesn't know about). */
|
|
14
24
|
forceReloadSdkSession?: () => void;
|
|
25
|
+
timelineElements: TimelineElement[];
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
interface PatchTarget {
|
|
@@ -24,7 +35,21 @@ interface PatchTarget {
|
|
|
24
35
|
interface GroupGeometry {
|
|
25
36
|
bbox: { left: number; top: number; width: number; height: number };
|
|
26
37
|
targets: PatchTarget[];
|
|
27
|
-
rebases: Array<{ target: PatchTarget; left: number; top: number }>;
|
|
38
|
+
rebases: Array<{ target: PatchTarget; left: number; top: number; track?: number }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The member's current resolved track (authored, or the runtime's positional-
|
|
42
|
+
// index fallback). Threaded through so the server can stamp it explicitly —
|
|
43
|
+
// same hazard and fix as the razor split.
|
|
44
|
+
function resolveAuthoredTrack(
|
|
45
|
+
selection: ElementMatchSelection,
|
|
46
|
+
timelineElements: TimelineElement[],
|
|
47
|
+
): number | undefined {
|
|
48
|
+
const id = findMatchingTimelineElementId(selection, timelineElements);
|
|
49
|
+
if (!id) return undefined;
|
|
50
|
+
const match = timelineElements.find((el) => (el.key ?? el.id) === id);
|
|
51
|
+
if (!match) return undefined;
|
|
52
|
+
return resolveElementTrack(match);
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
// Wrapper sits at the members' bounding box top-left; each member is rebased so
|
|
@@ -32,13 +57,17 @@ interface GroupGeometry {
|
|
|
32
57
|
// composition space (transforms excluded), exactly the space the rebase formula
|
|
33
58
|
// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
|
|
34
59
|
// transform deltas and stay correct without adjustment.
|
|
35
|
-
function computeGroupGeometry(
|
|
60
|
+
export function computeGroupGeometry(
|
|
61
|
+
members: DomEditSelection[],
|
|
62
|
+
timelineElements: TimelineElement[],
|
|
63
|
+
): GroupGeometry {
|
|
36
64
|
const boxes = members.map((m) => ({
|
|
37
65
|
target: buildDomEditPatchTarget(m),
|
|
38
66
|
left: m.element.offsetLeft,
|
|
39
67
|
top: m.element.offsetTop,
|
|
40
68
|
right: m.element.offsetLeft + m.element.offsetWidth,
|
|
41
69
|
bottom: m.element.offsetTop + m.element.offsetHeight,
|
|
70
|
+
track: resolveAuthoredTrack(m, timelineElements),
|
|
42
71
|
}));
|
|
43
72
|
const left = Math.min(...boxes.map((b) => b.left));
|
|
44
73
|
const top = Math.min(...boxes.map((b) => b.top));
|
|
@@ -47,10 +76,37 @@ function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
|
|
|
47
76
|
return {
|
|
48
77
|
bbox: { left, top, width, height },
|
|
49
78
|
targets: boxes.map((b) => b.target),
|
|
50
|
-
rebases: boxes.map((b) => ({
|
|
79
|
+
rebases: boxes.map((b) => ({
|
|
80
|
+
target: b.target,
|
|
81
|
+
left: b.left - left,
|
|
82
|
+
top: b.top - top,
|
|
83
|
+
track: b.track,
|
|
84
|
+
})),
|
|
51
85
|
};
|
|
52
86
|
}
|
|
53
87
|
|
|
88
|
+
// Ungroup re-derives each child's track from the raw DOM (no DomEditSelection
|
|
89
|
+
// exists per child); matches by id, falling back to hfId for a child with no
|
|
90
|
+
// authored id. A child with neither keeps its current track, unstamped.
|
|
91
|
+
export function resolveGroupChildTracks(
|
|
92
|
+
group: DomEditSelection,
|
|
93
|
+
timelineElements: TimelineElement[],
|
|
94
|
+
): Array<{ target: PatchTarget; track?: number }> {
|
|
95
|
+
const sourceFile = group.sourceFile || "index.html";
|
|
96
|
+
const result: Array<{ target: PatchTarget; track?: number }> = [];
|
|
97
|
+
for (const child of Array.from(group.element.children)) {
|
|
98
|
+
const id = (child as HTMLElement).id || undefined;
|
|
99
|
+
const hfId = readHfId(child);
|
|
100
|
+
if (!id && !hfId) continue;
|
|
101
|
+
const track = resolveAuthoredTrack(
|
|
102
|
+
{ id, hfId, sourceFile, isCompositionHost: false },
|
|
103
|
+
timelineElements,
|
|
104
|
+
);
|
|
105
|
+
result.push({ target: buildDomEditPatchTarget({ id, hfId }), track });
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
54
110
|
// Shared read → mutate-route → save-with-history → reload pipeline for both
|
|
55
111
|
// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
|
|
56
112
|
// useElementLifecycleOps (delete). Returns the route's JSON, or throws.
|
|
@@ -103,7 +159,7 @@ async function commitStructuralMutation(
|
|
|
103
159
|
}
|
|
104
160
|
|
|
105
161
|
export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
106
|
-
const { activeCompPath, showToast, projectIdRef } = params;
|
|
162
|
+
const { activeCompPath, showToast, projectIdRef, timelineElements } = params;
|
|
107
163
|
|
|
108
164
|
const groupSelection = useCallback(
|
|
109
165
|
async (members: DomEditSelection[]): Promise<string | null> => {
|
|
@@ -121,7 +177,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
121
177
|
// Auto-name "Group N" by the count of existing groups in the document.
|
|
122
178
|
const doc = members[0].element.ownerDocument;
|
|
123
179
|
const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
|
|
124
|
-
const { bbox, targets, rebases } = computeGroupGeometry(members);
|
|
180
|
+
const { bbox, targets, rebases } = computeGroupGeometry(members, timelineElements);
|
|
125
181
|
|
|
126
182
|
try {
|
|
127
183
|
const data = await commitStructuralMutation(
|
|
@@ -138,7 +194,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
138
194
|
return null;
|
|
139
195
|
}
|
|
140
196
|
},
|
|
141
|
-
[activeCompPath, projectIdRef, showToast, params],
|
|
197
|
+
[activeCompPath, projectIdRef, showToast, params, timelineElements],
|
|
142
198
|
);
|
|
143
199
|
|
|
144
200
|
const ungroupSelection = useCallback(
|
|
@@ -146,13 +202,14 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
146
202
|
const pid = projectIdRef.current;
|
|
147
203
|
if (!pid) return;
|
|
148
204
|
const targetPath = group.sourceFile || activeCompPath || "index.html";
|
|
205
|
+
const childTracks = resolveGroupChildTracks(group, timelineElements);
|
|
149
206
|
|
|
150
207
|
try {
|
|
151
208
|
await commitStructuralMutation(
|
|
152
209
|
pid,
|
|
153
210
|
targetPath,
|
|
154
211
|
"unwrap-elements",
|
|
155
|
-
{ target: buildDomEditPatchTarget(group) },
|
|
212
|
+
{ target: buildDomEditPatchTarget(group), childTracks },
|
|
156
213
|
"Ungroup elements",
|
|
157
214
|
params,
|
|
158
215
|
);
|
|
@@ -160,7 +217,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
|
160
217
|
showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
|
|
161
218
|
}
|
|
162
219
|
},
|
|
163
|
-
[activeCompPath, projectIdRef, showToast, params],
|
|
220
|
+
[activeCompPath, projectIdRef, showToast, params, timelineElements],
|
|
164
221
|
);
|
|
165
222
|
|
|
166
223
|
return { groupSelection, ungroupSelection };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
|
|
7
|
+
import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
|
|
8
|
+
import { useActiveComposition } from "./useActiveComposition";
|
|
9
|
+
import { useFileTree } from "./useFileTree";
|
|
10
|
+
import {
|
|
11
|
+
isHydratedFromUrlState,
|
|
12
|
+
readStudioUrlStateFromWindow,
|
|
13
|
+
resolveMasterCompositionPath,
|
|
14
|
+
} from "../utils/studioUrlState";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
let root: Root | null = null;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (root) act(() => root?.unmount());
|
|
22
|
+
root = null;
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
window.location.hash = "";
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("useResetSelectionOnProjectSwitch", () => {
|
|
28
|
+
function ResetHarness({ projectId }: { projectId: string | null }) {
|
|
29
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
30
|
+
const [activeCompPath, setActiveCompPath] = useState<string | null>("stale-comp.html");
|
|
31
|
+
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(true);
|
|
32
|
+
useResetSelectionOnProjectSwitch({
|
|
33
|
+
projectId,
|
|
34
|
+
initialUrlStateRef,
|
|
35
|
+
setActiveCompPath,
|
|
36
|
+
setActiveCompPathHydrated,
|
|
37
|
+
});
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-active-comp-path={activeCompPath ?? ""}
|
|
41
|
+
data-hydrated={String(activeCompPathHydrated)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it("does not reset on the initial resolution from null", () => {
|
|
47
|
+
const host = document.createElement("div");
|
|
48
|
+
document.body.append(host);
|
|
49
|
+
root = createRoot(host);
|
|
50
|
+
act(() => {
|
|
51
|
+
root?.render(<ResetHarness projectId={null} />);
|
|
52
|
+
});
|
|
53
|
+
act(() => {
|
|
54
|
+
root?.render(<ResetHarness projectId="project-a" />);
|
|
55
|
+
});
|
|
56
|
+
const el = host.firstElementChild as HTMLElement;
|
|
57
|
+
expect(el.dataset.activeCompPath).toBe("stale-comp.html");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resets activeCompPath and re-derives hydrated on an actual project switch", () => {
|
|
61
|
+
const host = document.createElement("div");
|
|
62
|
+
document.body.append(host);
|
|
63
|
+
root = createRoot(host);
|
|
64
|
+
act(() => {
|
|
65
|
+
root?.render(<ResetHarness projectId="project-a" />);
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
root?.render(<ResetHarness projectId="project-b" />);
|
|
69
|
+
});
|
|
70
|
+
const el = host.firstElementChild as HTMLElement;
|
|
71
|
+
expect(el.dataset.activeCompPath).toBe("");
|
|
72
|
+
expect(el.dataset.hydrated).toBe("true");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("project switch integration (mirrors App.tsx's wiring)", () => {
|
|
77
|
+
function AppLikeHarness({
|
|
78
|
+
projectId,
|
|
79
|
+
masterCompPath,
|
|
80
|
+
onSelectComposition,
|
|
81
|
+
}: {
|
|
82
|
+
projectId: string | null;
|
|
83
|
+
masterCompPath: string | null;
|
|
84
|
+
onSelectComposition: (comp: string) => void;
|
|
85
|
+
}) {
|
|
86
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
87
|
+
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
88
|
+
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
|
|
89
|
+
isHydratedFromUrlState(initialUrlStateRef.current),
|
|
90
|
+
);
|
|
91
|
+
// Mirrors handleSelectComposition (useCompositionContentLoader): selecting a composition
|
|
92
|
+
// also updates activeCompPath, which is exactly the state that must be reset on switch.
|
|
93
|
+
const handleSelect = (comp: string) => {
|
|
94
|
+
setActiveCompPath(comp);
|
|
95
|
+
onSelectComposition(comp);
|
|
96
|
+
};
|
|
97
|
+
// Mirrors useActiveComposition's wiring: reset as a sibling call, before auto-open.
|
|
98
|
+
useResetSelectionOnProjectSwitch({
|
|
99
|
+
projectId,
|
|
100
|
+
initialUrlStateRef,
|
|
101
|
+
setActiveCompPath,
|
|
102
|
+
setActiveCompPathHydrated,
|
|
103
|
+
});
|
|
104
|
+
useAutoOpenRootComposition({
|
|
105
|
+
projectId,
|
|
106
|
+
activeCompPath,
|
|
107
|
+
activeCompPathHydrated,
|
|
108
|
+
masterCompPath,
|
|
109
|
+
onSelectComposition: handleSelect,
|
|
110
|
+
});
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it("opens the new project's root after switching projects with a composition already open", () => {
|
|
115
|
+
const onSelectComposition = vi.fn();
|
|
116
|
+
const host = document.createElement("div");
|
|
117
|
+
document.body.append(host);
|
|
118
|
+
root = createRoot(host);
|
|
119
|
+
|
|
120
|
+
// Project A resolves and auto-opens its root, which also sets activeCompPath — the
|
|
121
|
+
// "something already open" state a real user would be in before switching projects.
|
|
122
|
+
act(() => {
|
|
123
|
+
root?.render(
|
|
124
|
+
<AppLikeHarness
|
|
125
|
+
projectId="project-a"
|
|
126
|
+
masterCompPath="index.html"
|
|
127
|
+
onSelectComposition={onSelectComposition}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
|
|
132
|
+
onSelectComposition.mockClear();
|
|
133
|
+
|
|
134
|
+
// Switch to project B in-session (hash change, no remount) — this is the exact scenario
|
|
135
|
+
// the adversarial review found broken: activeCompPath was never reset, so project B's
|
|
136
|
+
// auto-open silently no-op'd because activeCompPath still read as "already set".
|
|
137
|
+
act(() => {
|
|
138
|
+
root?.render(
|
|
139
|
+
<AppLikeHarness
|
|
140
|
+
projectId="project-b"
|
|
141
|
+
masterCompPath="hero.html"
|
|
142
|
+
onSelectComposition={onSelectComposition}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("hero.html");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("full stack: useFileTree -> useActiveComposition (masterCompPath staleness)", () => {
|
|
152
|
+
const PROJECT_DATA: Record<string, { files: string[]; compositions: string[] }> = {
|
|
153
|
+
"project-a": { files: ["index.html"], compositions: ["index.html"] },
|
|
154
|
+
"project-b": { files: ["hero.html"], compositions: ["hero.html"] },
|
|
155
|
+
// A preset/vendor .html can be in `files` without being a real composition —
|
|
156
|
+
// this is the exact shape a review found auto-open mis-targeting.
|
|
157
|
+
"project-c": { files: ["preset.html", "hero.html"], compositions: ["hero.html"] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function mockFetch(url: string): Promise<Response> {
|
|
161
|
+
const match = /^\/api\/projects\/([^/]+)$/.exec(url);
|
|
162
|
+
if (match) {
|
|
163
|
+
const data = PROJECT_DATA[decodeURIComponent(match[1])] ?? { files: [], compositions: [] };
|
|
164
|
+
return Promise.resolve(new Response(JSON.stringify(data), { status: 200 }));
|
|
165
|
+
}
|
|
166
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function FullStackHarness({ projectId }: { projectId: string | null }) {
|
|
170
|
+
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
171
|
+
const projectIdRef = useRef(projectId);
|
|
172
|
+
projectIdRef.current = projectId;
|
|
173
|
+
const { fileTree, fileTreeLoaded, compositions } = useFileTree({ projectId, projectIdRef });
|
|
174
|
+
const masterCompPath = useMemo(
|
|
175
|
+
() => resolveMasterCompositionPath(compositions),
|
|
176
|
+
[compositions],
|
|
177
|
+
);
|
|
178
|
+
const { activeCompPath } = useActiveComposition({
|
|
179
|
+
projectId,
|
|
180
|
+
initialUrlStateRef,
|
|
181
|
+
fileTree,
|
|
182
|
+
fileTreeLoaded,
|
|
183
|
+
masterCompPath,
|
|
184
|
+
setEditingFile: vi.fn(),
|
|
185
|
+
showToast: vi.fn(),
|
|
186
|
+
});
|
|
187
|
+
return <div data-active-comp-path={activeCompPath ?? ""} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("opens project B's real root, not project A's stale master path, on an in-session switch", async () => {
|
|
191
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
|
|
192
|
+
const host = document.createElement("div");
|
|
193
|
+
document.body.append(host);
|
|
194
|
+
root = createRoot(host);
|
|
195
|
+
|
|
196
|
+
await act(async () => {
|
|
197
|
+
root?.render(<FullStackHarness projectId="project-a" />);
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
});
|
|
201
|
+
let el = host.firstElementChild as HTMLElement;
|
|
202
|
+
expect(el.dataset.activeCompPath).toBe("index.html");
|
|
203
|
+
|
|
204
|
+
// Switch in-session (no remount) before asserting — this is the exact race the
|
|
205
|
+
// adversarial review found: project A's fileTree/masterCompPath are still in state
|
|
206
|
+
// for at least one render after projectId flips, until B's own fetch resolves.
|
|
207
|
+
await act(async () => {
|
|
208
|
+
root?.render(<FullStackHarness projectId="project-b" />);
|
|
209
|
+
await Promise.resolve();
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
});
|
|
212
|
+
el = host.firstElementChild as HTMLElement;
|
|
213
|
+
expect(el.dataset.activeCompPath).toBe("hero.html");
|
|
214
|
+
|
|
215
|
+
fetchSpy.mockRestore();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("auto-opens the real composition, not a non-composition .html earlier in the file list", async () => {
|
|
219
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
|
|
220
|
+
const host = document.createElement("div");
|
|
221
|
+
document.body.append(host);
|
|
222
|
+
root = createRoot(host);
|
|
223
|
+
|
|
224
|
+
await act(async () => {
|
|
225
|
+
root?.render(<FullStackHarness projectId="project-c" />);
|
|
226
|
+
await Promise.resolve();
|
|
227
|
+
await Promise.resolve();
|
|
228
|
+
});
|
|
229
|
+
const el = host.firstElementChild as HTMLElement;
|
|
230
|
+
expect(el.dataset.activeCompPath).toBe("hero.html");
|
|
231
|
+
|
|
232
|
+
fetchSpy.mockRestore();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
|
2
|
+
import {
|
|
3
|
+
isHydratedFromUrlState,
|
|
4
|
+
readStudioUrlStateFromWindow,
|
|
5
|
+
type StudioUrlState,
|
|
6
|
+
} from "../utils/studioUrlState";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resets selection when `projectId` switches in-session. Runs during render, not an
|
|
10
|
+
* effect, so the caller never sees a stale activeCompPath in the same commit.
|
|
11
|
+
*/
|
|
12
|
+
export function useResetSelectionOnProjectSwitch({
|
|
13
|
+
projectId,
|
|
14
|
+
initialUrlStateRef,
|
|
15
|
+
setActiveCompPath,
|
|
16
|
+
setActiveCompPathHydrated,
|
|
17
|
+
}: {
|
|
18
|
+
projectId: string | null;
|
|
19
|
+
initialUrlStateRef: MutableRefObject<StudioUrlState>;
|
|
20
|
+
setActiveCompPath: Dispatch<SetStateAction<string | null>>;
|
|
21
|
+
setActiveCompPathHydrated: Dispatch<SetStateAction<boolean>>;
|
|
22
|
+
}): void {
|
|
23
|
+
const previousProjectIdRef = useRef(projectId);
|
|
24
|
+
|
|
25
|
+
if (previousProjectIdRef.current !== projectId) {
|
|
26
|
+
const isSwitch = previousProjectIdRef.current !== null;
|
|
27
|
+
previousProjectIdRef.current = projectId;
|
|
28
|
+
if (isSwitch) {
|
|
29
|
+
initialUrlStateRef.current = readStudioUrlStateFromWindow();
|
|
30
|
+
setActiveCompPath(null);
|
|
31
|
+
setActiveCompPathHydrated(isHydratedFromUrlState(initialUrlStateRef.current));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({
|
|
|
12
12
|
import type { Composition } from "@hyperframes/sdk";
|
|
13
13
|
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
|
14
14
|
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
|
|
17
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
18
|
+
|
|
19
|
+
const trackMock = vi.mocked(trackStudioEvent);
|
|
20
|
+
|
|
21
|
+
function Probe({ projectId }: { projectId: string }) {
|
|
22
|
+
useSdkSession(projectId, "index.html");
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
27
|
|
|
17
28
|
function fakeSession(): Composition {
|
|
@@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => {
|
|
|
135
146
|
expect(published.dispose).toHaveBeenCalledOnce();
|
|
136
147
|
});
|
|
137
148
|
});
|
|
149
|
+
|
|
150
|
+
describe("useSdkSession unavailable telemetry", () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
openComposition.mockReset();
|
|
153
|
+
trackMock.mockClear();
|
|
154
|
+
class FakeEventSource {
|
|
155
|
+
addEventListener(): void {}
|
|
156
|
+
close(): void {}
|
|
157
|
+
}
|
|
158
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.unstubAllGlobals();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Every cutover chokepoint silently takes the server path when there is no
|
|
166
|
+
// session, and the shadow never runs either — so a missing session is a total,
|
|
167
|
+
// otherwise-invisible SDK bypass. These three exits are its only origin.
|
|
168
|
+
it("reports an unreadable composition", async () => {
|
|
169
|
+
vi.stubGlobal(
|
|
170
|
+
"fetch",
|
|
171
|
+
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
|
|
172
|
+
);
|
|
173
|
+
const root = createRoot(document.createElement("div"));
|
|
174
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
175
|
+
await flushAsyncEffects();
|
|
176
|
+
|
|
177
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
|
|
178
|
+
await act(async () => root.unmount());
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports a parse failure with its message", async () => {
|
|
182
|
+
vi.stubGlobal(
|
|
183
|
+
"fetch",
|
|
184
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
185
|
+
);
|
|
186
|
+
openComposition.mockRejectedValue(new Error("unparseable composition"));
|
|
187
|
+
|
|
188
|
+
const root = createRoot(document.createElement("div"));
|
|
189
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
190
|
+
await flushAsyncEffects();
|
|
191
|
+
|
|
192
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
193
|
+
stage: "open",
|
|
194
|
+
error: "unparseable composition",
|
|
195
|
+
});
|
|
196
|
+
await act(async () => root.unmount());
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("stays silent on the happy path", async () => {
|
|
200
|
+
vi.stubGlobal(
|
|
201
|
+
"fetch",
|
|
202
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
203
|
+
);
|
|
204
|
+
openComposition.mockResolvedValue(fakeSession());
|
|
205
|
+
|
|
206
|
+
const root = createRoot(document.createElement("div"));
|
|
207
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
208
|
+
await flushAsyncEffects();
|
|
209
|
+
|
|
210
|
+
expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
|
|
211
|
+
await act(async () => root.unmount());
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -181,7 +181,15 @@ export function useSdkSession(
|
|
|
181
181
|
|
|
182
182
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
183
|
.then(async (content) => {
|
|
184
|
-
if (cancelled
|
|
184
|
+
if (cancelled) return;
|
|
185
|
+
if (typeof content !== "string") {
|
|
186
|
+
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
|
+
// server path and emits nothing — the shadow never runs either. This
|
|
188
|
+
// is the only place a missing session can originate, so a broken read
|
|
189
|
+
// would otherwise be a silent, total SDK bypass.
|
|
190
|
+
trackStudioEvent("sdk_session_unavailable", { stage: "read" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
185
193
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
186
194
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
187
195
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -206,6 +214,7 @@ export function useSdkSession(
|
|
|
206
214
|
)
|
|
207
215
|
) {
|
|
208
216
|
disposeSdkSession(comp);
|
|
217
|
+
trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });
|
|
209
218
|
return;
|
|
210
219
|
}
|
|
211
220
|
const displaced = ownedSessionRef.current;
|
|
@@ -215,8 +224,17 @@ export function useSdkSession(
|
|
|
215
224
|
setOwnedSession(installed);
|
|
216
225
|
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
|
|
217
226
|
})
|
|
218
|
-
.catch(() => {
|
|
219
|
-
if (!cancelled && generationRef.current === generation)
|
|
227
|
+
.catch((error: unknown) => {
|
|
228
|
+
if (!cancelled && generationRef.current === generation) {
|
|
229
|
+
setOwnedSession(null);
|
|
230
|
+
// openComposition threw (unparseable composition, OOM) — same total
|
|
231
|
+
// bypass as the read failure above, but this one is a real defect
|
|
232
|
+
// rather than a missing file. Carry the message; it is the only clue.
|
|
233
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
234
|
+
stage: "open",
|
|
235
|
+
error: error instanceof Error ? error.message : String(error),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
220
238
|
});
|
|
221
239
|
|
|
222
240
|
return () => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
16
|
+
|
|
17
|
+
function renderDropHook(
|
|
18
|
+
sourceContent: string,
|
|
19
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
20
|
+
) {
|
|
21
|
+
vi.stubGlobal(
|
|
22
|
+
"fetch",
|
|
23
|
+
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
|
|
24
|
+
);
|
|
25
|
+
let drop: DropFn | null = null;
|
|
26
|
+
function Harness() {
|
|
27
|
+
const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
|
|
28
|
+
projectIdRef: { current: "project" },
|
|
29
|
+
activeCompPath: "index.html",
|
|
30
|
+
timelineElements: [],
|
|
31
|
+
showToast: vi.fn(),
|
|
32
|
+
writeProjectFile,
|
|
33
|
+
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
reloadPreview: vi.fn(),
|
|
35
|
+
uploadProjectFiles: vi.fn().mockResolvedValue([]),
|
|
36
|
+
});
|
|
37
|
+
drop = handleTimelineAssetDrop;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
mountReactHarness(<Harness />);
|
|
41
|
+
return () => drop!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
45
|
+
it("grows the root duration when the drop lands past the current end", async () => {
|
|
46
|
+
const source =
|
|
47
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
48
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
49
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
50
|
+
|
|
51
|
+
await act(async () => {
|
|
52
|
+
await getDrop()("clip.mp4", { start: 8, track: 0 }, 5);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
56
|
+
expect(written).toContain('data-duration="13"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves the root duration alone when the drop lands inside the current end", async () => {
|
|
60
|
+
const source =
|
|
61
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
62
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
63
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
70
|
+
expect(written).toContain('data-duration="10"');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -20,6 +20,7 @@ import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelp
|
|
|
20
20
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
22
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
|
+
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
23
24
|
import { usePlayerStore } from "../player";
|
|
24
25
|
|
|
25
26
|
interface UseTimelineAssetDropOpsOptions {
|
|
@@ -89,22 +90,25 @@ export function useTimelineAssetDropOps({
|
|
|
89
90
|
);
|
|
90
91
|
const newElementZIndex = Math.max(1, relevantElements.length + 1);
|
|
91
92
|
|
|
92
|
-
const patchedContent =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
93
|
+
const patchedContent = extendRootDurationInSource(
|
|
94
|
+
insertTimelineAssetIntoSource(
|
|
95
|
+
originalContent,
|
|
96
|
+
buildTimelineAssetInsertHtml({
|
|
97
|
+
id: newId,
|
|
98
|
+
hfId: `hf-${generateId()}`,
|
|
99
|
+
assetPath: resolvedAssetSrc,
|
|
100
|
+
kind,
|
|
101
|
+
start: normalizedStart,
|
|
102
|
+
duration: normalizedDuration,
|
|
103
|
+
track: placement.track,
|
|
104
|
+
zIndex: newElementZIndex,
|
|
105
|
+
geometry: fitTimelineAssetGeometry(
|
|
106
|
+
null,
|
|
107
|
+
resolveTimelineAssetCompositionSize(originalContent),
|
|
108
|
+
),
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
normalizedStart + normalizedDuration,
|
|
108
112
|
);
|
|
109
113
|
|
|
110
114
|
await saveProjectFilesWithHistory({
|