@hyperframes/studio 0.7.71 → 0.7.73
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-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +7762 -4261
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +10 -4
- package/src/components/editor/AnimationCard.test.tsx +100 -0
- package/src/components/editor/AnimationCard.tsx +5 -2
- package/src/components/editor/EaseCurveSection.test.tsx +348 -0
- package/src/components/editor/EaseCurveSection.tsx +444 -237
- package/src/components/editor/EaseParamFields.test.tsx +188 -0
- package/src/components/editor/EaseParamFields.tsx +246 -0
- package/src/components/editor/PropertyPanel.test.tsx +9 -7
- package/src/components/editor/PropertyPanelFlat.tsx +75 -88
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
- package/src/components/editor/easeCurveSvg.tsx +65 -0
- package/src/components/editor/easePresetLibrary.test.ts +114 -0
- package/src/components/editor/easePresetLibrary.ts +49 -0
- package/src/components/editor/gsapAnimationConstants.ts +15 -13
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
- package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
- package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +331 -10
- package/src/components/editor/useColorGradingController.ts +57 -90
- package/src/components/editor/useColorGradingPreviews.ts +350 -0
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/contexts/PanelLayoutContext.tsx +3 -6
- package/src/hooks/useBlockCatalog.ts +31 -13
- package/src/hooks/useBlockHandlers.ts +22 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePanelLayout.test.ts +68 -1
- package/src/hooks/usePanelLayout.ts +72 -34
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +68 -229
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/components/SpeedMenu.tsx +1 -2
- package/src/telemetry/events.test.ts +9 -0
- package/src/telemetry/events.ts +5 -0
- package/src/utils/blockInstaller.test.ts +67 -0
- package/src/utils/blockInstaller.ts +162 -109
- package/src/utils/studioUiPreferences.test.ts +5 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/assets/index-CSCh2Vrp.js +0 -428
- package/dist/assets/index-Ceyz8Qt2.css +0 -1
- package/src/player/components/PlayerControls.test.ts +0 -20
- package/src/player/components/useSeekBarDrag.ts +0 -169
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
|
|
7
|
+
import { useColorGradingScopes } from "./useColorGradingScopes";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function Harness({
|
|
18
|
+
open,
|
|
19
|
+
refreshKey,
|
|
20
|
+
captureFrame,
|
|
21
|
+
}: {
|
|
22
|
+
open: boolean;
|
|
23
|
+
refreshKey: string;
|
|
24
|
+
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
|
|
25
|
+
}) {
|
|
26
|
+
const scopes = useColorGradingScopes({ open, refreshKey, captureFrame });
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<span data-status>{scopes.status}</span>
|
|
30
|
+
<span data-analysis>{scopes.analysis ? "ready" : "empty"}</span>
|
|
31
|
+
<button type="button" onClick={scopes.refresh}>
|
|
32
|
+
Refresh
|
|
33
|
+
</button>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function installPixelDecode() {
|
|
39
|
+
vi.spyOn(HTMLImageElement.prototype, "decode").mockResolvedValue();
|
|
40
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
41
|
+
drawImage: vi.fn(),
|
|
42
|
+
getImageData: vi.fn().mockReturnValue({
|
|
43
|
+
data: new Uint8ClampedArray([255, 255, 255, 255]),
|
|
44
|
+
}),
|
|
45
|
+
} as unknown as CanvasRenderingContext2D);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderScopeHarness({ open, refreshKey = "a" }: { open: boolean; refreshKey?: string }) {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
installPixelDecode();
|
|
51
|
+
const captureFrame = vi.fn().mockResolvedValue({
|
|
52
|
+
dataUrl: "data:image/png;base64,scope",
|
|
53
|
+
width: 1,
|
|
54
|
+
height: 1,
|
|
55
|
+
});
|
|
56
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
57
|
+
const root = createRoot(host);
|
|
58
|
+
act(() =>
|
|
59
|
+
root.render(<Harness open={open} refreshKey={refreshKey} captureFrame={captureFrame} />),
|
|
60
|
+
);
|
|
61
|
+
return { captureFrame, host, root };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("useColorGradingScopes", () => {
|
|
65
|
+
it("captures once on open rather than polling during playback", async () => {
|
|
66
|
+
const { captureFrame, host, root } = renderScopeHarness({ open: false });
|
|
67
|
+
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
|
68
|
+
expect(captureFrame).not.toHaveBeenCalled();
|
|
69
|
+
|
|
70
|
+
act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
|
|
71
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
72
|
+
expect(captureFrame).toHaveBeenCalledOnce();
|
|
73
|
+
expect(host.querySelector("[data-status]")?.textContent).toBe("ready");
|
|
74
|
+
|
|
75
|
+
await act(async () => vi.advanceTimersByTimeAsync(5000));
|
|
76
|
+
expect(captureFrame).toHaveBeenCalledOnce();
|
|
77
|
+
act(() => root.unmount());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("refreshes only after a committed grading key or explicit request", async () => {
|
|
81
|
+
const { captureFrame, host, root } = renderScopeHarness({ open: true });
|
|
82
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
83
|
+
|
|
84
|
+
act(() => root.render(<Harness open refreshKey="b" captureFrame={captureFrame} />));
|
|
85
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
86
|
+
expect(captureFrame).toHaveBeenCalledTimes(2);
|
|
87
|
+
|
|
88
|
+
act(() => {
|
|
89
|
+
host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
90
|
+
});
|
|
91
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
92
|
+
expect(captureFrame).toHaveBeenCalledTimes(3);
|
|
93
|
+
act(() => root.unmount());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reports unavailable when capture returns no frame", async () => {
|
|
97
|
+
vi.useFakeTimers();
|
|
98
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
99
|
+
const root = createRoot(host);
|
|
100
|
+
act(() =>
|
|
101
|
+
root.render(<Harness open refreshKey="a" captureFrame={vi.fn().mockResolvedValue(null)} />),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
105
|
+
expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
|
|
106
|
+
act(() => root.unmount());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("clears stale analysis while a refresh is pending and when it is unavailable", async () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
installPixelDecode();
|
|
112
|
+
let resolveRefresh: (frame: ColorGradingCapturedFrame | null) => void = () => {};
|
|
113
|
+
const pendingRefresh = new Promise<ColorGradingCapturedFrame | null>((resolve) => {
|
|
114
|
+
resolveRefresh = resolve;
|
|
115
|
+
});
|
|
116
|
+
const captureFrame = vi
|
|
117
|
+
.fn()
|
|
118
|
+
.mockResolvedValueOnce({
|
|
119
|
+
dataUrl: "data:image/png;base64,scope",
|
|
120
|
+
width: 1,
|
|
121
|
+
height: 1,
|
|
122
|
+
})
|
|
123
|
+
.mockReturnValueOnce(pendingRefresh);
|
|
124
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
125
|
+
const root = createRoot(host);
|
|
126
|
+
act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
|
|
127
|
+
|
|
128
|
+
await act(async () => vi.advanceTimersByTimeAsync(180));
|
|
129
|
+
expect(host.querySelector("[data-analysis]")?.textContent).toBe("ready");
|
|
130
|
+
|
|
131
|
+
act(() => {
|
|
132
|
+
host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
133
|
+
});
|
|
134
|
+
act(() => vi.advanceTimersByTime(180));
|
|
135
|
+
expect(host.querySelector("[data-status]")?.textContent).toBe("loading");
|
|
136
|
+
expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
|
|
137
|
+
|
|
138
|
+
await act(async () => resolveRefresh(null));
|
|
139
|
+
expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
|
|
140
|
+
expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
|
|
141
|
+
act(() => root.unmount());
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
|
|
3
|
+
import {
|
|
4
|
+
analyzeColorGradingFrame,
|
|
5
|
+
readColorGradingFramePixels,
|
|
6
|
+
type ColorGradingScopeAnalysis,
|
|
7
|
+
} from "./colorGradingFrameAnalysis";
|
|
8
|
+
|
|
9
|
+
type ColorGradingScopeStatus = "idle" | "loading" | "ready" | "unavailable";
|
|
10
|
+
const CAPTURE_DEBOUNCE_MS = 180;
|
|
11
|
+
|
|
12
|
+
export function useColorGradingScopes({
|
|
13
|
+
open,
|
|
14
|
+
captureFrame,
|
|
15
|
+
refreshKey,
|
|
16
|
+
}: {
|
|
17
|
+
open: boolean;
|
|
18
|
+
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
|
|
19
|
+
refreshKey: string;
|
|
20
|
+
}) {
|
|
21
|
+
const [analysis, setAnalysis] = useState<ColorGradingScopeAnalysis | null>(null);
|
|
22
|
+
const [status, setStatus] = useState<ColorGradingScopeStatus>("idle");
|
|
23
|
+
const [refreshVersion, setRefreshVersion] = useState(0);
|
|
24
|
+
const captureRef = useRef(captureFrame);
|
|
25
|
+
captureRef.current = captureFrame;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!open) {
|
|
29
|
+
setAnalysis(null);
|
|
30
|
+
setStatus("idle");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
const timer = window.setTimeout(async () => {
|
|
35
|
+
setAnalysis(null);
|
|
36
|
+
setStatus("loading");
|
|
37
|
+
try {
|
|
38
|
+
const frame = await captureRef.current();
|
|
39
|
+
if (cancelled) return;
|
|
40
|
+
if (!frame) {
|
|
41
|
+
setStatus("unavailable");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const pixels = await readColorGradingFramePixels(frame);
|
|
45
|
+
if (!pixels) throw new Error("Canvas 2D unavailable");
|
|
46
|
+
const next = analyzeColorGradingFrame(pixels, frame.width, frame.height);
|
|
47
|
+
if (!cancelled) {
|
|
48
|
+
setAnalysis(next);
|
|
49
|
+
setStatus("ready");
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
if (!cancelled) setStatus("unavailable");
|
|
53
|
+
}
|
|
54
|
+
}, CAPTURE_DEBOUNCE_MS);
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
window.clearTimeout(timer);
|
|
58
|
+
};
|
|
59
|
+
}, [open, refreshKey, refreshVersion]);
|
|
60
|
+
|
|
61
|
+
return { analysis, status, refresh: () => setRefreshVersion((version) => version + 1) };
|
|
62
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
/** One owner for continuous inspector edits: preview freely, persist once. */
|
|
4
4
|
export function useInspectorGestureTransaction<T>({
|
|
@@ -58,3 +58,32 @@ export function useInspectorGestureTransaction<T>({
|
|
|
58
58
|
|
|
59
59
|
return { begin, preview, settle, cancel, activeRef };
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
export function useInspectorGestureDraft<T>({
|
|
63
|
+
sourceValue,
|
|
64
|
+
onPreview,
|
|
65
|
+
onCommit,
|
|
66
|
+
}: {
|
|
67
|
+
sourceValue: T;
|
|
68
|
+
onPreview: (value: T) => void;
|
|
69
|
+
onCommit: (value: T) => void;
|
|
70
|
+
}) {
|
|
71
|
+
const [draft, setDraft] = useState(sourceValue);
|
|
72
|
+
const transaction = useInspectorGestureTransaction({
|
|
73
|
+
sourceValue,
|
|
74
|
+
onPreview: (next) => {
|
|
75
|
+
setDraft(next);
|
|
76
|
+
onPreview(next);
|
|
77
|
+
},
|
|
78
|
+
onCommit: (next) => {
|
|
79
|
+
setDraft(next);
|
|
80
|
+
onCommit(next);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!transaction.activeRef.current) setDraft(sourceValue);
|
|
86
|
+
}, [sourceValue, transaction.activeRef]);
|
|
87
|
+
|
|
88
|
+
return { draft, setDraft, transaction };
|
|
89
|
+
}
|
|
@@ -19,7 +19,7 @@ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
|
19
19
|
|
|
20
20
|
// Timeline gets a generous default height so the preview isn't oversized and the
|
|
21
21
|
// tracks have room to breathe (CapCut-style). Users can still drag the divider.
|
|
22
|
-
const DEFAULT_TIMELINE_H =
|
|
22
|
+
const DEFAULT_TIMELINE_H = 360;
|
|
23
23
|
|
|
24
24
|
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
|
25
25
|
return compositionLoading;
|
|
@@ -14,9 +14,8 @@ export function usePanelLayoutContext(): PanelLayoutValue {
|
|
|
14
14
|
export function PanelLayoutProvider({
|
|
15
15
|
value: {
|
|
16
16
|
leftWidth,
|
|
17
|
-
setLeftWidth,
|
|
18
17
|
rightWidth,
|
|
19
|
-
|
|
18
|
+
adjustPanelWidth,
|
|
20
19
|
leftCollapsed,
|
|
21
20
|
setLeftCollapsed,
|
|
22
21
|
rightCollapsed,
|
|
@@ -39,9 +38,8 @@ export function PanelLayoutProvider({
|
|
|
39
38
|
const stable = useMemo<PanelLayoutValue>(
|
|
40
39
|
() => ({
|
|
41
40
|
leftWidth,
|
|
42
|
-
setLeftWidth,
|
|
43
41
|
rightWidth,
|
|
44
|
-
|
|
42
|
+
adjustPanelWidth,
|
|
45
43
|
leftCollapsed,
|
|
46
44
|
setLeftCollapsed,
|
|
47
45
|
rightCollapsed,
|
|
@@ -58,9 +56,8 @@ export function PanelLayoutProvider({
|
|
|
58
56
|
}),
|
|
59
57
|
[
|
|
60
58
|
leftWidth,
|
|
61
|
-
setLeftWidth,
|
|
62
59
|
rightWidth,
|
|
63
|
-
|
|
60
|
+
adjustPanelWidth,
|
|
64
61
|
leftCollapsed,
|
|
65
62
|
setLeftCollapsed,
|
|
66
63
|
rightCollapsed,
|
|
@@ -6,33 +6,51 @@ import {
|
|
|
6
6
|
resolveBlockCategory,
|
|
7
7
|
} from "../utils/blockCategories";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
type CatalogItem = RegistryItem & {
|
|
10
10
|
category: BlockCategory;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
let catalogCache: CatalogItem[] | null = null;
|
|
14
|
+
let catalogRequest: Promise<CatalogItem[]> | null = null;
|
|
15
|
+
|
|
16
|
+
function loadCatalog(): Promise<CatalogItem[]> {
|
|
17
|
+
catalogRequest ??= fetch("/api/registry/blocks")
|
|
18
|
+
.then((response) => {
|
|
19
|
+
if (!response.ok) throw new Error("Failed to load catalog");
|
|
20
|
+
return response.json() as Promise<RegistryItem[]>;
|
|
21
|
+
})
|
|
22
|
+
.then((items) => {
|
|
23
|
+
catalogCache = items
|
|
24
|
+
.map((item) => ({ ...item, category: resolveBlockCategory(item.tags) }))
|
|
25
|
+
.sort((a, b) => {
|
|
26
|
+
const aIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === a.category);
|
|
27
|
+
const bIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === b.category);
|
|
28
|
+
return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
|
|
29
|
+
});
|
|
30
|
+
return catalogCache;
|
|
31
|
+
})
|
|
32
|
+
.catch((error: unknown) => {
|
|
33
|
+
catalogRequest = null;
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
return catalogRequest;
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
export function useBlockCatalog() {
|
|
14
|
-
const [blocks, setBlocks] = useState<CatalogItem[]>([]);
|
|
15
|
-
const [loading, setLoading] = useState(
|
|
40
|
+
const [blocks, setBlocks] = useState<CatalogItem[]>(() => catalogCache ?? []);
|
|
41
|
+
const [loading, setLoading] = useState(catalogCache === null);
|
|
16
42
|
const [error, setError] = useState<string | null>(null);
|
|
17
43
|
const [search, setSearch] = useState("");
|
|
18
44
|
const [category, setCategory] = useState<BlockCategory | null>(null);
|
|
19
45
|
|
|
20
46
|
// fallow-ignore-next-line complexity
|
|
21
47
|
useEffect(() => {
|
|
48
|
+
if (catalogCache) return;
|
|
22
49
|
let cancelled = false;
|
|
23
50
|
(async () => {
|
|
24
51
|
try {
|
|
25
|
-
const
|
|
26
|
-
if (!res.ok) throw new Error("Failed to load catalog");
|
|
27
|
-
const data = (await res.json()) as RegistryItem[];
|
|
52
|
+
const items = await loadCatalog();
|
|
28
53
|
if (cancelled) return;
|
|
29
|
-
const items = data
|
|
30
|
-
.map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
|
|
31
|
-
.sort((a, b) => {
|
|
32
|
-
const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
|
|
33
|
-
const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
|
|
34
|
-
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
35
|
-
});
|
|
36
54
|
setBlocks(items);
|
|
37
55
|
} catch (err) {
|
|
38
56
|
if (cancelled) return;
|
|
@@ -9,6 +9,7 @@ import { addBlockToProject } from "../utils/blockInstaller";
|
|
|
9
9
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
10
10
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
11
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
12
|
+
import type { MediaOverlayPlacement } from "../components/editor/propertyPanelTypes";
|
|
12
13
|
|
|
13
14
|
interface BlockCtxDeps {
|
|
14
15
|
activeCompPath: string | null;
|
|
@@ -46,6 +47,7 @@ export interface UseBlockHandlersResult {
|
|
|
46
47
|
>;
|
|
47
48
|
handleAddBlock: (blockName: string) => void;
|
|
48
49
|
handleTimelineBlockDrop: (blockName: string, placement: { start: number; track: number }) => void;
|
|
50
|
+
handleAddMediaOverlay: (blockName: string, placement: MediaOverlayPlacement) => Promise<void>;
|
|
49
51
|
handlePreviewBlockDrop: (blockName: string, position: { left: number; top: number }) => void;
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -151,6 +153,25 @@ export function useBlockHandlers({
|
|
|
151
153
|
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
152
154
|
);
|
|
153
155
|
|
|
156
|
+
const handleAddMediaOverlay = useCallback(
|
|
157
|
+
async (blockName: string, placement: MediaOverlayPlacement) => {
|
|
158
|
+
if (!projectId) return;
|
|
159
|
+
const { compositionPath, ...timelinePlacement } = placement;
|
|
160
|
+
await runBlockInstall(blockName, () =>
|
|
161
|
+
addBlockToProject({
|
|
162
|
+
projectId,
|
|
163
|
+
blockName,
|
|
164
|
+
...blockCtx,
|
|
165
|
+
activeCompPath: compositionPath ?? blockCtx.activeCompPath,
|
|
166
|
+
placement: timelinePlacement,
|
|
167
|
+
previewIframe: previewIframeRef.current,
|
|
168
|
+
currentTime: usePlayerStore.getState().currentTime,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
[projectId, blockCtx, previewIframeRef, runBlockInstall],
|
|
173
|
+
);
|
|
174
|
+
|
|
154
175
|
const handlePreviewBlockDrop = useCallback(
|
|
155
176
|
(blockName: string, position: { left: number; top: number }) => {
|
|
156
177
|
if (!projectId) return;
|
|
@@ -173,6 +194,7 @@ export function useBlockHandlers({
|
|
|
173
194
|
setActiveBlockParams,
|
|
174
195
|
handleAddBlock,
|
|
175
196
|
handleTimelineBlockDrop,
|
|
197
|
+
handleAddMediaOverlay,
|
|
176
198
|
handlePreviewBlockDrop,
|
|
177
199
|
};
|
|
178
200
|
}
|
|
@@ -283,6 +283,21 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
describe("runCommit — instantPatch wiring", () => {
|
|
286
|
+
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
|
|
287
|
+
mockFetchResult({ changed: false });
|
|
288
|
+
const deps = renderCommitHook();
|
|
289
|
+
|
|
290
|
+
await act(async () => {
|
|
291
|
+
await deps.api.commitMutation(
|
|
292
|
+
selection,
|
|
293
|
+
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 100 },
|
|
294
|
+
{ label: "Move keyframe" },
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
|
299
|
+
});
|
|
300
|
+
|
|
286
301
|
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
|
287
302
|
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
288
303
|
mockFetchResult({ changed: false });
|
|
@@ -77,6 +77,17 @@ async function mutateGsapScriptBatch(
|
|
|
77
77
|
|
|
78
78
|
type ShowToast = (message: string, tone?: "error" | "info") => void;
|
|
79
79
|
|
|
80
|
+
function showUnchangedMutationFeedback(
|
|
81
|
+
mutations: Record<string, unknown>[],
|
|
82
|
+
result: MutationResult,
|
|
83
|
+
showToast: ShowToast | undefined,
|
|
84
|
+
): void {
|
|
85
|
+
if (result.changed !== false || mutations.length !== 1) return;
|
|
86
|
+
if (mutations[0]?.type === "move-keyframe") {
|
|
87
|
+
showToast?.("A keyframe already exists at that time", "info");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
async function runMutationRequest(
|
|
81
92
|
mutations: Record<string, unknown>[],
|
|
82
93
|
options: CommitMutationOptions,
|
|
@@ -94,7 +105,9 @@ async function runMutationRequest(
|
|
|
94
105
|
);
|
|
95
106
|
}
|
|
96
107
|
try {
|
|
97
|
-
|
|
108
|
+
const result = await request();
|
|
109
|
+
showUnchangedMutationFeedback(mutations, result, showToast);
|
|
110
|
+
return result;
|
|
98
111
|
} catch (error) {
|
|
99
112
|
if (error instanceof GsapMutationHttpError) {
|
|
100
113
|
showToast?.(formatGsapMutationRejectionToast(error), "error");
|
|
@@ -2,11 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
6
7
|
import { usePanelLayout } from "./usePanelLayout";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
const entries = new Map<string, string>();
|
|
13
|
+
Object.defineProperty(window, "localStorage", {
|
|
14
|
+
configurable: true,
|
|
15
|
+
value: {
|
|
16
|
+
get length() {
|
|
17
|
+
return entries.size;
|
|
18
|
+
},
|
|
19
|
+
clear: () => entries.clear(),
|
|
20
|
+
getItem: (key: string) => entries.get(key) ?? null,
|
|
21
|
+
key: (index: number) => Array.from(entries.keys())[index] ?? null,
|
|
22
|
+
removeItem: (key: string) => entries.delete(key),
|
|
23
|
+
setItem: (key: string, value: string) => entries.set(key, value),
|
|
24
|
+
} satisfies Storage,
|
|
25
|
+
});
|
|
26
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1496 });
|
|
27
|
+
});
|
|
28
|
+
|
|
10
29
|
afterEach(() => {
|
|
11
30
|
document.body.innerHTML = "";
|
|
12
31
|
vi.doUnmock("../components/editor/manualEditingAvailability");
|
|
@@ -42,6 +61,54 @@ function renderPanelLayout() {
|
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
describe("usePanelLayout — right inspector panes", () => {
|
|
64
|
+
it("opens Design with the intended viewport-scaled panel widths", () => {
|
|
65
|
+
const harness = renderPanelLayout();
|
|
66
|
+
|
|
67
|
+
expect(harness.getState()).toMatchObject({
|
|
68
|
+
leftWidth: 384,
|
|
69
|
+
rightWidth: 424,
|
|
70
|
+
rightCollapsed: false,
|
|
71
|
+
rightPanelTab: "design",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
harness.unmount();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("persists the latest pointer width even before React rerenders", () => {
|
|
78
|
+
const harness = renderPanelLayout();
|
|
79
|
+
const state = harness.getState();
|
|
80
|
+
const target = { setPointerCapture: vi.fn() };
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
state.handlePanelResizeStart("left", {
|
|
84
|
+
preventDefault: vi.fn(),
|
|
85
|
+
target,
|
|
86
|
+
pointerId: 1,
|
|
87
|
+
clientX: 100,
|
|
88
|
+
} as unknown as React.PointerEvent);
|
|
89
|
+
state.handlePanelResizeMove({ clientX: 140 } as React.PointerEvent);
|
|
90
|
+
state.handlePanelResizeEnd();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(harness.getState().leftWidth).toBe(424);
|
|
94
|
+
expect(readStudioUiPreferences().leftWidth).toBe(424);
|
|
95
|
+
harness.unmount();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accumulates and persists rapid keyboard resize steps", () => {
|
|
99
|
+
const harness = renderPanelLayout();
|
|
100
|
+
const state = harness.getState();
|
|
101
|
+
|
|
102
|
+
act(() => {
|
|
103
|
+
state.adjustPanelWidth("right", 16);
|
|
104
|
+
state.adjustPanelWidth("right", 16);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(harness.getState().rightWidth).toBe(456);
|
|
108
|
+
expect(readStudioUiPreferences().rightWidth).toBe(456);
|
|
109
|
+
harness.unmount();
|
|
110
|
+
});
|
|
111
|
+
|
|
45
112
|
it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
|
|
46
113
|
const harness = renderPanelLayout();
|
|
47
114
|
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|