@hyperframes/studio 0.6.122-alpha.0 → 0.7.1
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/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/assets/index-Z4bA3Fi0.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3768 -2245
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -41
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditPreviewSync.ts +3 -0
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +12 -11
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- 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-${
|
|
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}-${
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
});
|