@hyperframes/studio 0.7.86 → 0.7.88
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-BL3qfGEQ.js → hyperframes-player-B03_wTTS.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
- package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6265 -4922
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- package/dist/assets/index-D379YOKT.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.88",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,16 +41,17 @@
|
|
|
41
41
|
"@codemirror/theme-one-dark": "^6.1.2",
|
|
42
42
|
"@codemirror/view": "6.40.0",
|
|
43
43
|
"@phosphor-icons/react": "^2.1.10",
|
|
44
|
+
"@tanstack/react-virtual": "^3.14.6",
|
|
44
45
|
"bpm-detective": "^2.0.5",
|
|
45
46
|
"dompurify": "^3.2.4",
|
|
46
47
|
"gsap": "^3.13.0",
|
|
47
48
|
"marked": "^14.1.4",
|
|
48
49
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/core": "0.7.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/player": "0.7.
|
|
52
|
-
"@hyperframes/sdk": "0.7.
|
|
53
|
-
"@hyperframes/studio-server": "0.7.
|
|
50
|
+
"@hyperframes/core": "0.7.88",
|
|
51
|
+
"@hyperframes/parsers": "0.7.88",
|
|
52
|
+
"@hyperframes/player": "0.7.88",
|
|
53
|
+
"@hyperframes/sdk": "0.7.88",
|
|
54
|
+
"@hyperframes/studio-server": "0.7.88"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@types/react": "19",
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"vite": "^6.4.2",
|
|
66
67
|
"vitest": "^3.2.4",
|
|
67
68
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
69
|
+
"@hyperframes/producer": "0.7.88"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|
|
71
72
|
"react": "19",
|
|
@@ -77,7 +78,9 @@
|
|
|
77
78
|
"build": "vite build && tsup",
|
|
78
79
|
"typecheck": "tsc --noEmit",
|
|
79
80
|
"test": "vitest run",
|
|
81
|
+
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
|
80
82
|
"test:watch": "vitest",
|
|
81
|
-
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
|
|
83
|
+
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
|
|
84
|
+
"test:timeline-default": "bun run test:timeline-virtualization"
|
|
82
85
|
}
|
|
83
86
|
}
|
|
@@ -118,8 +118,13 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
118
118
|
// the selection changes, not only on the next playhead tick.
|
|
119
119
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
120
120
|
const elements = usePlayerStore((s) => s.elements);
|
|
121
|
+
const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
|
|
121
122
|
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
122
|
-
const displayedTimelineZoomPercent = getTimelineZoomPercent(
|
|
123
|
+
const displayedTimelineZoomPercent = getTimelineZoomPercent(
|
|
124
|
+
zoomMode,
|
|
125
|
+
manualZoomPercent,
|
|
126
|
+
timelineFitPps,
|
|
127
|
+
);
|
|
123
128
|
const {
|
|
124
129
|
state: keyframeState,
|
|
125
130
|
isMotionPath: keyframeIsMotionPath,
|
|
@@ -418,7 +423,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
418
423
|
onClick={() => {
|
|
419
424
|
setZoomMode("manual");
|
|
420
425
|
setManualZoomPercent(
|
|
421
|
-
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent),
|
|
426
|
+
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps),
|
|
422
427
|
);
|
|
423
428
|
}}
|
|
424
429
|
className={flatIdle}
|
|
@@ -430,12 +435,14 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
430
435
|
type="range"
|
|
431
436
|
min="0"
|
|
432
437
|
max="100"
|
|
433
|
-
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent)}
|
|
438
|
+
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)}
|
|
434
439
|
title={`${displayedTimelineZoomPercent}%`}
|
|
435
440
|
aria-label="Timeline zoom"
|
|
436
441
|
onChange={(e) => {
|
|
437
442
|
setZoomMode("manual");
|
|
438
|
-
setManualZoomPercent(
|
|
443
|
+
setManualZoomPercent(
|
|
444
|
+
timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
|
|
445
|
+
);
|
|
439
446
|
}}
|
|
440
447
|
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
|
|
441
448
|
// track stays 2px and the thumb 10px, only the pointer box grows.
|
|
@@ -447,7 +454,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
447
454
|
aria-label="Zoom in"
|
|
448
455
|
onClick={() => {
|
|
449
456
|
setZoomMode("manual");
|
|
450
|
-
setManualZoomPercent(
|
|
457
|
+
setManualZoomPercent(
|
|
458
|
+
getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps),
|
|
459
|
+
);
|
|
451
460
|
}}
|
|
452
461
|
className={flatIdle}
|
|
453
462
|
>
|
|
@@ -48,6 +48,7 @@ export interface NLEContextValue {
|
|
|
48
48
|
compositionLoading: boolean;
|
|
49
49
|
setCompositionLoading: (loading: boolean) => void;
|
|
50
50
|
timelineDisabled: boolean;
|
|
51
|
+
timelineSessionEpoch: number;
|
|
51
52
|
hasLoadedOnceRef: React.MutableRefObject<boolean>;
|
|
52
53
|
// preview composition size (for preview block drop)
|
|
53
54
|
previewCompositionSize: { width: number; height: number } | null;
|
|
@@ -103,7 +104,7 @@ export function NLEProvider({
|
|
|
103
104
|
// project would otherwise keep rendering (and re-fetching from) the old project
|
|
104
105
|
// after switching.
|
|
105
106
|
useEffect(() => {
|
|
106
|
-
usePlayerStore.getState().
|
|
107
|
+
usePlayerStore.getState().beginTimelineSession(projectId);
|
|
107
108
|
useAssetPreviewStore.getState().clearPreviewAsset();
|
|
108
109
|
}, [projectId]);
|
|
109
110
|
|
|
@@ -289,6 +290,7 @@ export function NLEProvider({
|
|
|
289
290
|
setCompositionLoadingRaw(loading);
|
|
290
291
|
}, []);
|
|
291
292
|
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
|
|
293
|
+
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
|
292
294
|
|
|
293
295
|
useEffect(() => {
|
|
294
296
|
onCompositionLoadingChange?.(compositionLoading);
|
|
@@ -319,6 +321,7 @@ export function NLEProvider({
|
|
|
319
321
|
compositionLoading,
|
|
320
322
|
setCompositionLoading,
|
|
321
323
|
timelineDisabled,
|
|
324
|
+
timelineSessionEpoch,
|
|
322
325
|
hasLoadedOnceRef,
|
|
323
326
|
previewCompositionSize,
|
|
324
327
|
setPreviewCompositionSize,
|
|
@@ -127,6 +127,7 @@ export function TimelinePane({
|
|
|
127
127
|
persistTimelineH,
|
|
128
128
|
containerRef,
|
|
129
129
|
timelineDisabled,
|
|
130
|
+
timelineSessionEpoch,
|
|
130
131
|
} = useNLEContext();
|
|
131
132
|
|
|
132
133
|
// Move/resize/split come from the timeline edit context, not props — the
|
|
@@ -271,6 +272,7 @@ export function TimelinePane({
|
|
|
271
272
|
>
|
|
272
273
|
<div className="flex-shrink-0">{timelineToolbar}</div>
|
|
273
274
|
<Timeline
|
|
275
|
+
sessionEpoch={timelineSessionEpoch}
|
|
274
276
|
onSeek={seek}
|
|
275
277
|
onDrillDown={handleDrillDown}
|
|
276
278
|
renderClipContent={renderClipContent}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.unstubAllEnvs();
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe("Studio test mode", () => {
|
|
9
|
+
it("exposes hooks in the normal development runtime", async () => {
|
|
10
|
+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
|
|
11
|
+
|
|
12
|
+
expect(STUDIO_RUNTIME_MODE).toBe("development");
|
|
13
|
+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps hooks on the development server while it uses production React", async () => {
|
|
17
|
+
vi.stubEnv("DEV", false);
|
|
18
|
+
vi.stubEnv("MODE", "development");
|
|
19
|
+
|
|
20
|
+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
|
|
21
|
+
|
|
22
|
+
expect(STUDIO_RUNTIME_MODE).toBe("production");
|
|
23
|
+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("keeps test hooks out of an ordinary production build", async () => {
|
|
27
|
+
vi.stubEnv("DEV", false);
|
|
28
|
+
vi.stubEnv("MODE", "production");
|
|
29
|
+
|
|
30
|
+
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
|
|
31
|
+
|
|
32
|
+
expect(STUDIO_RUNTIME_MODE).toBe("production");
|
|
33
|
+
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type StudioRuntimeMode = "development" | "production";
|
|
2
|
+
|
|
3
|
+
function readStudioImportMetaEnv(): ImportMetaEnv | undefined {
|
|
4
|
+
try {
|
|
5
|
+
return import.meta.env;
|
|
6
|
+
} catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const studioImportMetaEnv = readStudioImportMetaEnv();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Test hooks belong to Vite's development server, even when that server uses
|
|
15
|
+
* production React for performance measurement. A production build has neither
|
|
16
|
+
* DEV nor the development server mode, so the API stays out of shipped assets.
|
|
17
|
+
*/
|
|
18
|
+
export const STUDIO_RUNTIME_MODE: StudioRuntimeMode = studioImportMetaEnv?.DEV
|
|
19
|
+
? "development"
|
|
20
|
+
: "production";
|
|
21
|
+
|
|
22
|
+
export const STUDIO_TEST_HOOKS_ENABLED =
|
|
23
|
+
studioImportMetaEnv?.DEV === true || studioImportMetaEnv?.MODE === "development";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
6
|
+
import { useGestureRecording, type GestureSample } from "./useGestureRecording";
|
|
7
|
+
|
|
8
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
document.body.replaceChildren();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("useGestureRecording", () => {
|
|
16
|
+
it("keeps only the latest gesture sample in each output frame", () => {
|
|
17
|
+
const animationFrames: FrameRequestCallback[] = [];
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"requestAnimationFrame",
|
|
20
|
+
vi.fn((callback: FrameRequestCallback) => {
|
|
21
|
+
animationFrames.push(callback);
|
|
22
|
+
return animationFrames.length;
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
26
|
+
let nowMs = 0;
|
|
27
|
+
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
|
|
28
|
+
|
|
29
|
+
const iframe = document.createElement("iframe");
|
|
30
|
+
document.body.append(iframe);
|
|
31
|
+
const element = iframe.contentDocument!.createElement("div");
|
|
32
|
+
element.id = "card";
|
|
33
|
+
iframe.contentDocument!.body.append(element);
|
|
34
|
+
|
|
35
|
+
let recording: ReturnType<typeof useGestureRecording> | null = null;
|
|
36
|
+
function Harness() {
|
|
37
|
+
recording = useGestureRecording();
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const root = mountReactHarness(<Harness />);
|
|
41
|
+
|
|
42
|
+
act(() => recording?.startRecording(element, iframe));
|
|
43
|
+
document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 10 }));
|
|
44
|
+
nowMs = 1;
|
|
45
|
+
act(() => animationFrames.shift()?.(1));
|
|
46
|
+
document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 20 }));
|
|
47
|
+
nowMs = 10;
|
|
48
|
+
act(() => animationFrames.shift()?.(10));
|
|
49
|
+
document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 40 }));
|
|
50
|
+
nowMs = 20;
|
|
51
|
+
act(() => animationFrames.shift()?.(20));
|
|
52
|
+
document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 50 }));
|
|
53
|
+
nowMs = 30;
|
|
54
|
+
act(() => animationFrames.shift()?.(30));
|
|
55
|
+
|
|
56
|
+
let samples: GestureSample[] = [];
|
|
57
|
+
act(() => {
|
|
58
|
+
samples = recording?.stopRecording() ?? [];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(samples).toEqual([
|
|
62
|
+
{ time: 0, properties: { x: 10, y: 0 } },
|
|
63
|
+
{ time: 1 / 30, properties: { x: 40, y: 0 } },
|
|
64
|
+
]);
|
|
65
|
+
act(() => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("releases the preview runtime when unmounted during a recording", () => {
|
|
69
|
+
const cancelAnimationFrame = vi.fn();
|
|
70
|
+
vi.stubGlobal(
|
|
71
|
+
"requestAnimationFrame",
|
|
72
|
+
vi.fn(() => 17),
|
|
73
|
+
);
|
|
74
|
+
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
|
|
75
|
+
|
|
76
|
+
const iframe = document.createElement("iframe");
|
|
77
|
+
document.body.append(iframe);
|
|
78
|
+
const previewDocument = iframe.contentDocument!;
|
|
79
|
+
const element = previewDocument.createElement("div");
|
|
80
|
+
element.id = "card";
|
|
81
|
+
element.style.visibility = "hidden";
|
|
82
|
+
element.style.setProperty("translate", "10px 20px");
|
|
83
|
+
element.style.setProperty("--hf-studio-offset-x", "12px");
|
|
84
|
+
element.style.setProperty("--hf-studio-offset-y", "-8px");
|
|
85
|
+
previewDocument.body.append(element);
|
|
86
|
+
|
|
87
|
+
const set = vi.fn();
|
|
88
|
+
const seek = vi.fn();
|
|
89
|
+
Reflect.set(iframe.contentWindow!, "gsap", {
|
|
90
|
+
getProperty: vi.fn(() => 0),
|
|
91
|
+
set,
|
|
92
|
+
});
|
|
93
|
+
Reflect.set(iframe.contentWindow!, "__timelines", {
|
|
94
|
+
main: { duration: () => 5, seek },
|
|
95
|
+
});
|
|
96
|
+
Reflect.set(iframe.contentWindow!, "__player", { getTime: () => 1 });
|
|
97
|
+
|
|
98
|
+
let recording: ReturnType<typeof useGestureRecording> | null = null;
|
|
99
|
+
function Harness() {
|
|
100
|
+
recording = useGestureRecording();
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const root = mountReactHarness(<Harness />);
|
|
104
|
+
|
|
105
|
+
act(() => recording?.startRecording(element, iframe, 4));
|
|
106
|
+
expect(element.style.getPropertyValue("--hf-studio-offset-x")).toBe("0px");
|
|
107
|
+
expect(element.style.getPropertyValue("--hf-studio-offset-y")).toBe("0px");
|
|
108
|
+
|
|
109
|
+
act(() => root.unmount());
|
|
110
|
+
|
|
111
|
+
expect(cancelAnimationFrame).toHaveBeenCalledWith(17);
|
|
112
|
+
expect(set).toHaveBeenCalledWith("#card", {
|
|
113
|
+
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
|
|
114
|
+
});
|
|
115
|
+
expect(element.style.visibility).toBe("hidden");
|
|
116
|
+
expect(element.style.getPropertyValue("translate")).toBe("10px 20px");
|
|
117
|
+
expect(element.style.getPropertyValue("--hf-studio-offset-x")).toBe("12px");
|
|
118
|
+
expect(element.style.getPropertyValue("--hf-studio-offset-y")).toBe("-8px");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime } from "../player/store/playerStore";
|
|
3
|
+
import { frameToSeconds, secondsToFrame } from "../player/lib/time";
|
|
3
4
|
|
|
4
5
|
export interface GestureSample {
|
|
5
6
|
time: number;
|
|
@@ -28,8 +29,8 @@ interface BasePosition {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
interface GsapRuntime {
|
|
31
|
-
seek: (t: number) => void;
|
|
32
|
-
set: (target: string, vars: Record<string, number | string>) => void;
|
|
32
|
+
timeline: { seek: (t: number) => void };
|
|
33
|
+
gsap: { set: (target: string, vars: Record<string, number | string>) => void };
|
|
33
34
|
selector: string;
|
|
34
35
|
element: HTMLElement;
|
|
35
36
|
startTime: number;
|
|
@@ -100,8 +101,8 @@ function connectGsapRuntime(
|
|
|
100
101
|
if (win?.gsap?.set && tl?.seek && selector) {
|
|
101
102
|
const tlDuration = tl.duration();
|
|
102
103
|
return {
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
timeline: tl,
|
|
105
|
+
gsap: win.gsap,
|
|
105
106
|
selector,
|
|
106
107
|
element,
|
|
107
108
|
startTime: win.__player?.getTime() ?? 0,
|
|
@@ -123,9 +124,9 @@ function applyRuntimePreview(
|
|
|
123
124
|
properties: Record<string, number>,
|
|
124
125
|
): void {
|
|
125
126
|
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
|
|
126
|
-
runtime.seek(seekTime);
|
|
127
|
+
runtime.timeline.seek(seekTime);
|
|
127
128
|
runtime.element.style.setProperty("translate", "none");
|
|
128
|
-
runtime.set(runtime.selector, { ...properties });
|
|
129
|
+
runtime.gsap.set(runtime.selector, { ...properties });
|
|
129
130
|
runtime.element.style.visibility = "visible";
|
|
130
131
|
liveTime.notify(seekTime);
|
|
131
132
|
usePlayerStore.getState().setCurrentTime(seekTime);
|
|
@@ -139,7 +140,18 @@ function recordSample(r: RecordingRefs, time: number, properties: Record<string,
|
|
|
139
140
|
// stripStudioEditsFromTarget in studio-api), so the keyframes must already
|
|
140
141
|
// include it. Subtracting it made the committed gesture play shoved off by the
|
|
141
142
|
// offset (the offset was removed twice).
|
|
142
|
-
|
|
143
|
+
const frame = secondsToFrame(time);
|
|
144
|
+
const sample = { time: frameToSeconds(frame), properties: { ...properties } };
|
|
145
|
+
const lastIndex = r.samples.length - 1;
|
|
146
|
+
const lastSample = r.samples[lastIndex];
|
|
147
|
+
// Gesture events follow the display refresh rate (often 60/120Hz), but the
|
|
148
|
+
// Studio preview is authored at 30fps. Keep the latest pointer state for each
|
|
149
|
+
// output frame so recording cannot create visually indistinguishable piles.
|
|
150
|
+
if (lastSample?.time === sample.time) {
|
|
151
|
+
r.samples[lastIndex] = sample;
|
|
152
|
+
} else {
|
|
153
|
+
r.samples.push(sample);
|
|
154
|
+
}
|
|
143
155
|
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
|
|
144
156
|
}
|
|
145
157
|
|
|
@@ -237,6 +249,26 @@ function createRecordingRefs(): RecordingRefs {
|
|
|
237
249
|
};
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
function releaseRuntimePreview(r: RecordingRefs): void {
|
|
253
|
+
const runtime = r.runtime;
|
|
254
|
+
if (!runtime) return;
|
|
255
|
+
const { element, savedVisibility, savedTranslate } = runtime;
|
|
256
|
+
element.style.visibility = savedVisibility;
|
|
257
|
+
element.style.setProperty("translate", savedTranslate || "");
|
|
258
|
+
try {
|
|
259
|
+
runtime.gsap.set(runtime.selector, {
|
|
260
|
+
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
|
|
261
|
+
});
|
|
262
|
+
} catch {
|
|
263
|
+
/* runtime gone */
|
|
264
|
+
}
|
|
265
|
+
if (r.cssVarOffset.x || r.cssVarOffset.y) {
|
|
266
|
+
element.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
|
|
267
|
+
element.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
|
|
268
|
+
}
|
|
269
|
+
r.runtime = null;
|
|
270
|
+
}
|
|
271
|
+
|
|
240
272
|
// ---------------------------------------------------------------------------
|
|
241
273
|
// Hook
|
|
242
274
|
// ---------------------------------------------------------------------------
|
|
@@ -259,9 +291,10 @@ export function useGestureRecording() {
|
|
|
259
291
|
useEffect(() => {
|
|
260
292
|
const r = refs.current;
|
|
261
293
|
return () => {
|
|
294
|
+
isRecordingRef.current = false;
|
|
295
|
+
releaseRuntimePreview(r);
|
|
262
296
|
r.cleanup?.();
|
|
263
297
|
r.cleanup = null;
|
|
264
|
-
isRecordingRef.current = false;
|
|
265
298
|
};
|
|
266
299
|
}, []);
|
|
267
300
|
|
|
@@ -290,15 +323,15 @@ export function useGestureRecording() {
|
|
|
290
323
|
// this scale to convert them to the iframe's composition pixels.
|
|
291
324
|
r.scale = computeIframeScale(iframeEl);
|
|
292
325
|
|
|
293
|
-
// Now clear the optimistic path offset (already folded into baseX/baseY).
|
|
294
|
-
if (base.cssOffX || base.cssOffY) {
|
|
295
|
-
element.style.setProperty("--hf-studio-offset-x", "0px");
|
|
296
|
-
element.style.setProperty("--hf-studio-offset-y", "0px");
|
|
297
|
-
}
|
|
298
|
-
|
|
299
326
|
// --- Phase 3: Connect to the iframe GSAP runtime ---
|
|
300
327
|
const selector = element.id ? `#${element.id}` : null;
|
|
301
328
|
r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
|
|
329
|
+
// Clear the optimistic path offset only while a live runtime owns the
|
|
330
|
+
// preview. releaseRuntimePreview restores it on every exit path.
|
|
331
|
+
if (r.runtime && (base.cssOffX || base.cssOffY)) {
|
|
332
|
+
element.style.setProperty("--hf-studio-offset-x", "0px");
|
|
333
|
+
element.style.setProperty("--hf-studio-offset-y", "0px");
|
|
334
|
+
}
|
|
302
335
|
|
|
303
336
|
// --- Phase 5: Attach event listeners ---
|
|
304
337
|
const handlePointerMove = (e: PointerEvent) => {
|
|
@@ -378,7 +411,7 @@ export function useGestureRecording() {
|
|
|
378
411
|
} catch {
|
|
379
412
|
// Preview failed — disable it for the rest of the gesture (recording
|
|
380
413
|
// continues). `r.runtime` is nulled so we don't retry on every frame.
|
|
381
|
-
r
|
|
414
|
+
releaseRuntimePreview(r);
|
|
382
415
|
}
|
|
383
416
|
}
|
|
384
417
|
|
|
@@ -407,31 +440,7 @@ export function useGestureRecording() {
|
|
|
407
440
|
if (!isRecordingRef.current) return [];
|
|
408
441
|
isRecordingRef.current = false;
|
|
409
442
|
const r = refs.current;
|
|
410
|
-
|
|
411
|
-
const { element: el, savedVisibility, savedTranslate } = r.runtime;
|
|
412
|
-
el.style.visibility = savedVisibility;
|
|
413
|
-
el.style.setProperty("translate", savedTranslate || "");
|
|
414
|
-
// Drop the gesture's inline gsap transform before re-applying the path
|
|
415
|
-
// offset below, so the two don't briefly stack (the recorded keyframes
|
|
416
|
-
// already encode the full position, offset included). On commit the
|
|
417
|
-
// re-seek lands on the gesture's first keyframe; on cancel this leaves the
|
|
418
|
-
// element at its pre-recording position.
|
|
419
|
-
try {
|
|
420
|
-
r.runtime.set(r.runtime.selector, {
|
|
421
|
-
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
|
|
422
|
-
});
|
|
423
|
-
} catch {
|
|
424
|
-
/* runtime gone */
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
if (r.cssVarOffset.x || r.cssVarOffset.y) {
|
|
428
|
-
const el = r.runtime?.element;
|
|
429
|
-
if (el) {
|
|
430
|
-
el.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
|
|
431
|
-
el.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
r.runtime = null;
|
|
443
|
+
releaseRuntimePreview(r);
|
|
435
444
|
r.cleanup?.();
|
|
436
445
|
r.cleanup = null;
|
|
437
446
|
const frozen = r.samples.slice();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
+
import {
|
|
7
|
+
createTimelinePerformanceFixture,
|
|
8
|
+
hasTimelinePerformanceFixtureLease,
|
|
9
|
+
setTimelinePerformanceFixtureLease,
|
|
10
|
+
type TimelinePerformanceFixtureProfile,
|
|
11
|
+
} from "../player/lib/timelinePerformanceFixture";
|
|
12
|
+
import { useStudioTestHooks } from "./useStudioTestHooks";
|
|
13
|
+
|
|
14
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
15
|
+
|
|
16
|
+
const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [
|
|
17
|
+
"dense-short",
|
|
18
|
+
"long-overlap",
|
|
19
|
+
"keyframe-heavy-expanded",
|
|
20
|
+
"composition-heavy",
|
|
21
|
+
"remote-unsupported",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function Probe(): null {
|
|
25
|
+
useStudioTestHooks({
|
|
26
|
+
previewIframeRef: { current: null },
|
|
27
|
+
buildDomSelectionFromTarget: vi.fn(),
|
|
28
|
+
applyDomSelection: vi.fn(),
|
|
29
|
+
});
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("timeline performance fixture", () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
setTimelinePerformanceFixtureLease(false);
|
|
36
|
+
window.__studioTest = undefined;
|
|
37
|
+
usePlayerStore.getState().reset();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("generates the expected dense-short 50k distribution", () => {
|
|
41
|
+
const first = createTimelinePerformanceFixture({
|
|
42
|
+
elementCount: 50_000,
|
|
43
|
+
profile: "dense-short",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(first.summary).toEqual({
|
|
47
|
+
elementCount: 50_000,
|
|
48
|
+
profile: "dense-short",
|
|
49
|
+
duration: 120,
|
|
50
|
+
trackCount: 1_000,
|
|
51
|
+
keyframedElementCount: 0,
|
|
52
|
+
expandedElementCount: 0,
|
|
53
|
+
});
|
|
54
|
+
expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000);
|
|
55
|
+
const perTrack = new Map<number, number>();
|
|
56
|
+
for (const element of first.elements) {
|
|
57
|
+
perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
|
|
63
|
+
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
64
|
+
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
65
|
+
expect(second).toEqual(first);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
|
|
69
|
+
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
|
|
70
|
+
expect(fixture.elements).toHaveLength(1_000);
|
|
71
|
+
expect(fixture.summary.elementCount).toBe(1_000);
|
|
72
|
+
expect(fixture.summary.duration).toBeGreaterThan(0);
|
|
73
|
+
expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000);
|
|
74
|
+
if (profile === "keyframe-heavy-expanded") {
|
|
75
|
+
expect(fixture.keyframeCache.size).toBe(1_000);
|
|
76
|
+
expect(fixture.gsapAnimations.size).toBe(1_000);
|
|
77
|
+
expect(fixture.expandedClipIds.size).toBe(1_000);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("publishes one dev-only loader that replaces fixture state atomically", () => {
|
|
82
|
+
const host = document.createElement("div");
|
|
83
|
+
const root = createRoot(host);
|
|
84
|
+
act(() => root.render(<Probe />));
|
|
85
|
+
const api = window.__studioTest;
|
|
86
|
+
expect(api).toBeDefined();
|
|
87
|
+
if (!api) throw new Error("Expected dev Studio test API");
|
|
88
|
+
expect(api.runtimeMode).toBe("development");
|
|
89
|
+
let notifications = 0;
|
|
90
|
+
usePlayerStore.setState({
|
|
91
|
+
isPlaying: true,
|
|
92
|
+
requestedSeekTime: 42,
|
|
93
|
+
clipRevealRequest: { elementId: "stale", nonce: 7 },
|
|
94
|
+
clipManifest: [],
|
|
95
|
+
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
|
|
96
|
+
});
|
|
97
|
+
const unsubscribe = usePlayerStore.subscribe(() => {
|
|
98
|
+
notifications += 1;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const summary = api.loadTimelinePerformanceFixture({
|
|
102
|
+
elementCount: 1_000,
|
|
103
|
+
profile: "keyframe-heavy-expanded",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(summary.elementCount).toBe(1_000);
|
|
107
|
+
expect(notifications).toBe(1);
|
|
108
|
+
expect(usePlayerStore.getState()).toMatchObject({
|
|
109
|
+
isPlaying: false,
|
|
110
|
+
requestedSeekTime: null,
|
|
111
|
+
clipRevealRequest: null,
|
|
112
|
+
clipManifest: null,
|
|
113
|
+
duration: 600,
|
|
114
|
+
timelineReady: true,
|
|
115
|
+
});
|
|
116
|
+
expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0);
|
|
117
|
+
expect(usePlayerStore.getState().elements).toHaveLength(1_000);
|
|
118
|
+
expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000);
|
|
119
|
+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
|
|
120
|
+
|
|
121
|
+
api.resetTimelinePerformanceFixture();
|
|
122
|
+
expect(notifications).toBe(2);
|
|
123
|
+
expect(usePlayerStore.getState()).toMatchObject({
|
|
124
|
+
isPlaying: false,
|
|
125
|
+
duration: 0,
|
|
126
|
+
timelineReady: false,
|
|
127
|
+
elements: [],
|
|
128
|
+
});
|
|
129
|
+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
|
|
130
|
+
unsubscribe();
|
|
131
|
+
act(() => root.unmount());
|
|
132
|
+
expect(window.__studioTest).toBeUndefined();
|
|
133
|
+
// The lease outlives the effect on purpose. Loading a fixture changes the
|
|
134
|
+
// player state this effect depends on, so the effect tears down right after
|
|
135
|
+
// the loader runs; releasing the lease there let live iframe discovery
|
|
136
|
+
// overwrite the fixture before anything could measure it.
|
|
137
|
+
expect(hasTimelinePerformanceFixtureLease()).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not mutate state when the fixture request is invalid", () => {
|
|
141
|
+
const host = document.createElement("div");
|
|
142
|
+
const root = createRoot(host);
|
|
143
|
+
act(() => root.render(<Probe />));
|
|
144
|
+
const api = window.__studioTest;
|
|
145
|
+
if (!api) throw new Error("Expected dev Studio test API");
|
|
146
|
+
const before = usePlayerStore.getState().elements;
|
|
147
|
+
|
|
148
|
+
expect(() =>
|
|
149
|
+
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
|
|
150
|
+
{ elementCount: 999, profile: "dense-short" },
|
|
151
|
+
]),
|
|
152
|
+
).toThrow("elementCount must be 1000 or 50000");
|
|
153
|
+
expect(usePlayerStore.getState().elements).toBe(before);
|
|
154
|
+
expect(() =>
|
|
155
|
+
Reflect.apply(api.loadTimelinePerformanceFixture, api, [
|
|
156
|
+
{ elementCount: 1_000, profile: "constructor" },
|
|
157
|
+
]),
|
|
158
|
+
).toThrow("Unknown timeline performance fixture profile");
|
|
159
|
+
act(() => root.unmount());
|
|
160
|
+
});
|
|
161
|
+
});
|