@hyperframes/studio 0.6.121 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -20,6 +20,7 @@ import type { SlideshowManifest, SlideHotspot } from "@hyperframes/core/slidesho
20
20
  import { usePlayerStore } from "../../player";
21
21
  import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
22
22
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
23
+ import { generateId } from "../../utils/generateId";
23
24
  import {
24
25
  SectionHeader,
25
26
  SlideList,
@@ -325,7 +326,7 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
325
326
 
326
327
  const handleCreateSequence = useCallback(
327
328
  (label: string) => {
328
- const id = `seq-${crypto.randomUUID()}`;
329
+ const id = `seq-${generateId()}`;
329
330
  applyManifest(createSequence(manifestRef.current, id, label)).catch(() => {});
330
331
  },
331
332
  [applyManifest],
@@ -7,6 +7,7 @@ import { useState, useCallback, useId } from "react";
7
7
  import type { SlideRef, SlideHotspot, SlideSequence } from "@hyperframes/core/slideshow";
8
8
  import type { DomEditSelection } from "../editor/domEditing";
9
9
  import type { SceneInfo } from "./slideshowPanelHelpers";
10
+ import { generateId } from "../../utils/generateId";
10
11
 
11
12
  // ── Section header (accordion toggle) ────────────────────────────────────
12
13
 
@@ -425,7 +426,7 @@ export function HotspotTool({
425
426
  // fallow-ignore-next-line complexity
426
427
  const handleMakeHotspot = useCallback(() => {
427
428
  if (!selectedSceneId || !targetSequenceId || !elementKey) return;
428
- const id = `hotspot-${elementKey}-${crypto.randomUUID()}`;
429
+ const id = `hotspot-${elementKey}-${generateId()}`;
429
430
  const label = hotspotLabel.trim() || elementKey;
430
431
  onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId });
431
432
  setHotspotLabel("");
@@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
119
119
  mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
120
120
  mov: {
121
121
  label: "MOV (ProRes 4444)",
122
- desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.",
122
+ desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
123
123
  },
124
124
  webm: {
125
125
  label: "WebM (VP9)",
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { trackStudioRenderStart } from "../../telemetry/events";
3
3
  import { getAnonymousId } from "../../telemetry/config";
4
+ import { generateId } from "../../utils/generateId";
4
5
 
5
6
  export interface RenderJob {
6
7
  id: string;
@@ -131,7 +132,7 @@ export function useRenderQueue(projectId: string | null) {
131
132
  });
132
133
  } catch {
133
134
  const failedJob: RenderJob = {
134
- id: crypto.randomUUID(),
135
+ id: generateId(),
135
136
  status: "failed",
136
137
  progress: 0,
137
138
  error: "Could not reach render server. Use `hyperframes render` from the CLI instead.",
@@ -143,7 +144,7 @@ export function useRenderQueue(projectId: string | null) {
143
144
  }
144
145
  if (!res.ok) {
145
146
  const failedJob: RenderJob = {
146
- id: crypto.randomUUID(),
147
+ id: generateId(),
147
148
  status: "failed",
148
149
  progress: 0,
149
150
  error: `Server error (${res.status}). Check the terminal for details.`,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Drag → GSAP position math, shared by the commit path
3
+ * (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
4
+ * the live preview (`manualOffsetDrag.applyManualOffsetDrag*`). Kept in its own
5
+ * leaf module — no store/runtime/core imports — so the live-preview file can use
6
+ * it without pulling the GSAP commit graph into its module scope.
7
+ */
8
+
9
+ /**
10
+ * Translate a studio drag offset into absolute GSAP x/y, accounting for the
11
+ * element's rotation and its drag-start base pose. Reads the drag-start
12
+ * attributes stamped by `createManualOffsetDragMember`
13
+ * (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase`
14
+ * is used when the base attributes are absent (e.g. a static element that GSAP
15
+ * hasn't given an x/y yet).
16
+ *
17
+ * Used by both the tweened commit and the static `set` commit / live preview, so
18
+ * the preview and the committed value agree by construction.
19
+ */
20
+ // fallow-ignore-next-line complexity
21
+ export function computeDraggedGsapPosition(
22
+ element: HTMLElement,
23
+ studioOffset: { x: number; y: number },
24
+ fallbackBase: { x: number; y: number },
25
+ ): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } {
26
+ const rotStyle = element.style.getPropertyValue("--hf-studio-rotation");
27
+ const rotDeg = Number.parseFloat(rotStyle) || 0;
28
+ const rad = (-rotDeg * Math.PI) / 180;
29
+ const cos = Math.cos(rad);
30
+ const sin = Math.sin(rad);
31
+ const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
32
+ const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
33
+ const deltaX = studioOffset.x - origX;
34
+ const deltaY = studioOffset.y - origY;
35
+ const adjX = deltaX * cos - deltaY * sin;
36
+ const adjY = deltaX * sin + deltaY * cos;
37
+ const parsedBaseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
38
+ const parsedBaseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
39
+ const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
40
+ const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
41
+ return {
42
+ newX: Math.round(baseGsapX + adjX),
43
+ newY: Math.round(baseGsapY + adjY),
44
+ baseGsapX,
45
+ baseGsapY,
46
+ };
47
+ }
@@ -0,0 +1,435 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
5
+ import {
6
+ commitStaticGsapPosition,
7
+ commitStaticGsapRotation,
8
+ parkPlayheadOnKeyframe,
9
+ type GsapDragCommitCallbacks,
10
+ } from "./gsapDragCommit";
11
+ import { usePlayerStore } from "../player/store/playerStore";
12
+
13
+ // Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
14
+ const selection = (): DomEditSelection =>
15
+ ({
16
+ id: "puck-a",
17
+ selector: "#puck-a",
18
+ element: {
19
+ style: { getPropertyValue: () => "", setProperty: () => {} },
20
+ getAttribute: () => null,
21
+ removeAttribute: () => {},
22
+ getBoundingClientRect: () => ({ top: 0, left: 0 }),
23
+ },
24
+ }) as unknown as DomEditSelection;
25
+
26
+ const flatTween = (): GsapAnimation =>
27
+ ({
28
+ id: "#puck-a-to",
29
+ targetSelector: "#puck-a",
30
+ method: "to",
31
+ resolvedStart: 1.2,
32
+ duration: 2.2,
33
+ properties: { x: -260 },
34
+ }) as unknown as GsapAnimation;
35
+
36
+ // What the flat tween becomes after convert-to-keyframes (returned by fetchAnimations).
37
+ const convertedTween = (): GsapAnimation =>
38
+ ({
39
+ id: "#puck-a-converted",
40
+ targetSelector: "#puck-a",
41
+ method: "to",
42
+ resolvedStart: 1.2,
43
+ duration: 2.2,
44
+ keyframes: {
45
+ keyframes: [
46
+ { percentage: 0, properties: { x: 0, y: 0 } },
47
+ { percentage: 100, properties: { x: -260, y: 0 } },
48
+ ],
49
+ },
50
+ }) as unknown as GsapAnimation;
51
+
52
+ function recordingCallbacks(): {
53
+ types: string[];
54
+ mutations: Array<Record<string, unknown>>;
55
+ callbacks: GsapDragCommitCallbacks;
56
+ } {
57
+ const types: string[] = [];
58
+ const mutations: Array<Record<string, unknown>> = [];
59
+ return {
60
+ types,
61
+ mutations,
62
+ callbacks: {
63
+ commitMutation: async (_sel, mutation) => {
64
+ types.push(mutation.type as string);
65
+ mutations.push(mutation);
66
+ },
67
+ fetchAnimations: async () => [convertedTween()],
68
+ },
69
+ };
70
+ }
71
+
72
+ describe("commitGsapPositionFromDrag — flat tween", () => {
73
+ beforeEach(() => {
74
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
75
+ });
76
+
77
+ it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
78
+ usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
79
+ const { types, callbacks } = recordingCallbacks();
80
+
81
+ await commitGsapPositionFromDrag(
82
+ selection(),
83
+ flatTween(),
84
+ { x: -100, y: 0 },
85
+ { x: 0, y: 0 },
86
+ null,
87
+ "#puck-a",
88
+ callbacks,
89
+ );
90
+
91
+ expect(types).toContain("convert-to-keyframes");
92
+ expect(types).toContain("replace-with-keyframes"); // the extend
93
+ expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
94
+ });
95
+
96
+ it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
97
+ usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
98
+ const { types, callbacks } = recordingCallbacks();
99
+
100
+ await commitGsapPositionFromDrag(
101
+ selection(),
102
+ flatTween(),
103
+ { x: -100, y: 0 },
104
+ { x: 0, y: 0 },
105
+ null,
106
+ "#puck-a",
107
+ callbacks,
108
+ );
109
+
110
+ expect(types).toContain("add-keyframe");
111
+ expect(types).not.toContain("add-with-keyframes");
112
+ });
113
+
114
+ it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => {
115
+ // User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
116
+ // the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
117
+ usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
118
+ const { types, mutations, callbacks } = recordingCallbacks();
119
+
120
+ await commitGsapPositionFromDrag(
121
+ selection(),
122
+ flatTween(),
123
+ { x: -100, y: 0 },
124
+ { x: 0, y: 0 },
125
+ null,
126
+ "#puck-a",
127
+ callbacks,
128
+ );
129
+
130
+ expect(types).toContain("add-keyframe");
131
+ expect(types).not.toContain("replace-with-keyframes"); // not extended
132
+ const addKf = mutations.find((m) => m.type === "add-keyframe");
133
+ expect(addKf?.percentage).toBe(100); // modified the selected endpoint
134
+ // consumed: cleared so the next free drag doesn't keep modifying
135
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
136
+ // parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur),
137
+ // so the edit is visible instead of rendering the base pose
138
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
139
+ });
140
+ });
141
+
142
+ describe("commitGsapPositionFromDrag — keyframed tween backfill", () => {
143
+ beforeEach(() => {
144
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
145
+ });
146
+
147
+ const keyframedTween = (): GsapAnimation =>
148
+ ({
149
+ id: "#puck-a-kf",
150
+ targetSelector: "#puck-a",
151
+ method: "to",
152
+ resolvedStart: 1.2,
153
+ duration: 2.2,
154
+ keyframes: {
155
+ keyframes: [
156
+ { percentage: 0, properties: { x: 0 } },
157
+ { percentage: 100, properties: { x: -260 } },
158
+ ],
159
+ },
160
+ }) as unknown as GsapAnimation;
161
+
162
+ it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => {
163
+ // Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe
164
+ // must carry backfillDefaults at the element's base so 100% gets y:0, not y:780.
165
+ usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 });
166
+ const { mutations, callbacks } = recordingCallbacks();
167
+
168
+ await commitGsapPositionFromDrag(
169
+ selection(),
170
+ keyframedTween(),
171
+ { x: 0, y: 780 }, // studioOffset: dragged straight down
172
+ { x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs)
173
+ null,
174
+ "#puck-a",
175
+ callbacks,
176
+ );
177
+
178
+ const addKf = mutations.find((m) => m.type === "add-keyframe");
179
+ expect(addKf).toBeDefined();
180
+ expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe
181
+ expect(addKf?.properties).toMatchObject({ y: 780 });
182
+ expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0
183
+ });
184
+ });
185
+
186
+ describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => {
187
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
188
+
189
+ const fromTween = (): GsapAnimation =>
190
+ ({
191
+ id: "#title-from-400",
192
+ targetSelector: "#title",
193
+ method: "from",
194
+ resolvedStart: 0.4,
195
+ duration: 0.9,
196
+ properties: { y: 70 },
197
+ }) as unknown as GsapAnimation;
198
+
199
+ it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => {
200
+ usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3]
201
+ const types: string[] = [];
202
+ const mutations: Array<Record<string, unknown>> = [];
203
+ const callbacks: GsapDragCommitCallbacks = {
204
+ commitMutation: async (_s, m) => {
205
+ types.push(m.type as string);
206
+ mutations.push(m);
207
+ },
208
+ // After split-into-property-groups, the position group is a from() tween (no keyframes).
209
+ fetchAnimations: async () => [
210
+ {
211
+ id: "#title-from-400-position",
212
+ targetSelector: "#title",
213
+ method: "from",
214
+ propertyGroup: "position",
215
+ resolvedStart: 0.4,
216
+ duration: 0.9,
217
+ properties: { y: 70 },
218
+ } as unknown as GsapAnimation,
219
+ ],
220
+ };
221
+
222
+ await commitGsapPositionFromDrag(
223
+ selection(),
224
+ fromTween(),
225
+ { x: 0, y: -333 },
226
+ { x: 0, y: 0 },
227
+ null,
228
+ "#title",
229
+ callbacks,
230
+ );
231
+
232
+ expect(types).toContain("split-into-property-groups");
233
+ expect(types).toContain("replace-with-keyframes");
234
+ expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
235
+ const replace = mutations.find((m) => m.type === "replace-with-keyframes");
236
+ expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from()
237
+ });
238
+ });
239
+
240
+ // Captures the OPTIONS each commit carries (not just the mutation) so we can
241
+ // assert which value-only commits attach the `instantPatch` fast path.
242
+ type RecordedCommit = { mutation: Record<string, unknown>; options: Record<string, unknown> };
243
+ function optionRecordingCallbacks(): {
244
+ commits: RecordedCommit[];
245
+ callbacks: GsapDragCommitCallbacks;
246
+ } {
247
+ const commits: RecordedCommit[] = [];
248
+ return {
249
+ commits,
250
+ callbacks: {
251
+ commitMutation: async (_sel, mutation, options) => {
252
+ commits.push({ mutation, options: options as Record<string, unknown> });
253
+ },
254
+ fetchAnimations: async () => [convertedTween()],
255
+ },
256
+ };
257
+ }
258
+
259
+ const existingPositionSet = (): GsapAnimation =>
260
+ ({
261
+ id: "#puck-a-set",
262
+ targetSelector: "#puck-a",
263
+ method: "set",
264
+ properties: { x: 10, y: 20 },
265
+ }) as unknown as GsapAnimation;
266
+
267
+ const existingRotationSet = (): GsapAnimation =>
268
+ ({
269
+ id: "#puck-a-rot-set",
270
+ targetSelector: "#puck-a",
271
+ method: "set",
272
+ properties: { rotation: 15 },
273
+ }) as unknown as GsapAnimation;
274
+
275
+ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
276
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
277
+
278
+ it("attaches an instantPatch to BOTH coalesced commits, each derived from its own mutation", async () => {
279
+ const { commits, callbacks } = optionRecordingCallbacks();
280
+
281
+ await commitStaticGsapPosition(
282
+ selection(),
283
+ { x: -50, y: 30 }, // studioOffset → newX/newY off a zero base
284
+ { x: 0, y: 0 },
285
+ "#puck-a",
286
+ existingPositionSet(),
287
+ callbacks,
288
+ );
289
+
290
+ expect(commits).toHaveLength(2);
291
+ // First (x) commit is the intermediate skipReload one — it now carries an
292
+ // instantPatch for just {x}, so if the SECOND POST fails the preview still
293
+ // reflects the x that DID persist (no reload, instant feedback).
294
+ expect(commits[0].options.skipReload).toBe(true);
295
+ expect(commits[0].options.instantPatch).toEqual({
296
+ selector: "#puck-a",
297
+ change: { kind: "set", props: { x: -50 } },
298
+ });
299
+ // Final (y) commit triggers the reload and carries the full {x,y} patch.
300
+ expect(commits[1].options.softReload).toBe(true);
301
+ expect(commits[1].options.instantPatch).toEqual({
302
+ selector: "#puck-a",
303
+ change: { kind: "set", props: { x: -50, y: 30 } },
304
+ });
305
+ });
306
+
307
+ it("derives each instantPatch's props from the value in the SAME mutation that's POSTed", async () => {
308
+ const { commits, callbacks } = optionRecordingCallbacks();
309
+
310
+ await commitStaticGsapPosition(
311
+ selection(),
312
+ { x: -50, y: 30 },
313
+ { x: 0, y: 0 },
314
+ "#puck-a",
315
+ existingPositionSet(),
316
+ callbacks,
317
+ );
318
+
319
+ // The patch values must equal the mutation values — they're read out of the
320
+ // same object, so a clean mutation can't ship alongside a stale patch.
321
+ const xMutation = commits[0].mutation as { property: string; value: number };
322
+ const yMutation = commits[1].mutation as { property: string; value: number };
323
+ const xPatch = commits[0].options.instantPatch as {
324
+ change: { props: Record<string, number> };
325
+ };
326
+ const yPatch = commits[1].options.instantPatch as {
327
+ change: { props: Record<string, number> };
328
+ };
329
+ expect(xPatch.change.props[xMutation.property]).toBe(xMutation.value);
330
+ expect(yPatch.change.props[yMutation.property]).toBe(yMutation.value);
331
+ // The y commit's combined patch also carries the x mutation's value.
332
+ expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
333
+ });
334
+
335
+ it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => {
336
+ const { commits, callbacks } = optionRecordingCallbacks();
337
+
338
+ await commitStaticGsapPosition(
339
+ selection(),
340
+ { x: -50, y: 30 },
341
+ { x: 0, y: 0 },
342
+ "#puck-a",
343
+ null, // no existing set → `add` a new tween
344
+ callbacks,
345
+ );
346
+
347
+ expect(commits).toHaveLength(1);
348
+ expect(commits[0].mutation.type).toBe("add");
349
+ expect(commits[0].options.instantPatch).toBeUndefined();
350
+ });
351
+ });
352
+
353
+ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
354
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
355
+
356
+ it("attaches instantPatch {kind:set, props:{rotation}} when updating an existing rotation set", async () => {
357
+ const { commits, callbacks } = optionRecordingCallbacks();
358
+
359
+ await commitStaticGsapRotation(selection(), 42, "#puck-a", existingRotationSet(), callbacks);
360
+
361
+ expect(commits).toHaveLength(1);
362
+ expect(commits[0].mutation.type).toBe("update-property");
363
+ expect(commits[0].options.instantPatch).toEqual({
364
+ selector: "#puck-a",
365
+ change: { kind: "set", props: { rotation: 42 } },
366
+ });
367
+ // Patch value derived from the SAME mutation that's POSTed (one source).
368
+ const m = commits[0].mutation as { property: string; value: number };
369
+ const patch = commits[0].options.instantPatch as {
370
+ change: { props: Record<string, number> };
371
+ };
372
+ expect(patch.change.props[m.property]).toBe(m.value);
373
+ });
374
+
375
+ it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {
376
+ const { commits, callbacks } = optionRecordingCallbacks();
377
+
378
+ await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
379
+
380
+ expect(commits).toHaveLength(1);
381
+ expect(commits[0].mutation.type).toBe("add");
382
+ expect(commits[0].options.instantPatch).toBeUndefined();
383
+ });
384
+ });
385
+
386
+ describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => {
387
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
388
+
389
+ it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => {
390
+ usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe
391
+ const { commits, callbacks } = optionRecordingCallbacks();
392
+
393
+ await commitGsapPositionFromDrag(
394
+ selection(),
395
+ flatTween(),
396
+ { x: -100, y: 0 },
397
+ { x: 0, y: 0 },
398
+ null,
399
+ "#puck-a",
400
+ callbacks,
401
+ );
402
+
403
+ // The keyframe path is structural here (convert + add-keyframe) and must rely
404
+ // on the soft reload — none of its commits opt into the instant patch.
405
+ expect(commits.length).toBeGreaterThan(0);
406
+ for (const c of commits) {
407
+ expect(c.options.instantPatch).toBeUndefined();
408
+ }
409
+ const types = commits.map((c) => c.mutation.type);
410
+ expect(types).toContain("convert-to-keyframes");
411
+ expect(types).toContain("add-keyframe");
412
+ });
413
+ });
414
+
415
+ describe("parkPlayheadOnKeyframe", () => {
416
+ beforeEach(() => usePlayerStore.setState({ requestedSeekTime: null }));
417
+
418
+ const tween = (): GsapAnimation =>
419
+ ({
420
+ id: "#x",
421
+ targetSelector: "#x",
422
+ method: "to",
423
+ resolvedStart: 1.2,
424
+ duration: 2.2,
425
+ }) as unknown as GsapAnimation;
426
+
427
+ it("seeks to the keyframe's absolute time so the element previews AT it, not at base", () => {
428
+ parkPlayheadOnKeyframe(tween(), 0); // tween start
429
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(1.2);
430
+ parkPlayheadOnKeyframe(tween(), 100); // tween end
431
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
432
+ parkPlayheadOnKeyframe(tween(), 50); // midpoint
433
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(2.3);
434
+ });
435
+ });