@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-B03_wTTS.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
  5. package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. 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.86",
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.86",
50
- "@hyperframes/parsers": "0.7.86",
51
- "@hyperframes/player": "0.7.86",
52
- "@hyperframes/sdk": "0.7.86",
53
- "@hyperframes/studio-server": "0.7.86"
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.86"
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(zoomMode, manualZoomPercent);
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(timelineSliderToZoomPercent(Number(e.target.value)));
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(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
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().reset();
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
- seek: tl.seek.bind(tl),
104
- set: win.gsap.set.bind(win.gsap),
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
- r.samples.push({ time, properties: { ...properties } });
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.runtime = null;
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
- if (r.runtime) {
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
+ });