@hyperframes/studio 0.8.127 → 0.8.129
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-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
- package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1396 -917
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +82 -63
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
1
|
+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
isTimeWithinTween,
|
|
6
|
+
resolveTweenDuration,
|
|
7
|
+
resolveTweenStart,
|
|
8
|
+
} from "../utils/globalTimeCompiler";
|
|
9
|
+
import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
|
|
10
|
+
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
6
11
|
import { roundTo3 } from "../utils/rounding";
|
|
7
12
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
8
13
|
import {
|
|
@@ -19,7 +24,8 @@ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
|
19
24
|
* server takes a replace-with-keyframes list verbatim, so an append-only build
|
|
20
25
|
* could hand it two keyframes a fraction of a percent apart. The invariant lives
|
|
21
26
|
* here rather than in each caller's own pre-check, which is how the two callers
|
|
22
|
-
* ended up with different tolerances in the first place.
|
|
27
|
+
* ended up with different tolerances in the first place. Arc writes are linear, so each
|
|
28
|
+
* keyframe moves to the time GSAP reached it and `percentage` is a time percentage.
|
|
23
29
|
*/
|
|
24
30
|
export function buildTemporalArcKeyframes(
|
|
25
31
|
anim: GsapAnimation,
|
|
@@ -27,7 +33,7 @@ export function buildTemporalArcKeyframes(
|
|
|
27
33
|
properties: Record<string, number>,
|
|
28
34
|
) {
|
|
29
35
|
return [
|
|
30
|
-
...(anim
|
|
36
|
+
...linearArcKeyframes(anim)
|
|
31
37
|
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
|
|
32
38
|
.map((keyframe) => ({
|
|
33
39
|
percentage: keyframe.percentage,
|
|
@@ -38,6 +44,46 @@ export function buildTemporalArcKeyframes(
|
|
|
38
44
|
].sort((a, b) => a.percentage - b.percentage);
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** An arc's keyframes at the time percentages GSAP plays them, for a write with `ease: "none"`. */
|
|
48
|
+
export function linearArcKeyframes(anim: GsapAnimation): GsapPercentageKeyframe[] {
|
|
49
|
+
const runEase = runEaseOf(anim);
|
|
50
|
+
return (anim.keyframes?.keyframes ?? []).map((keyframe) => ({
|
|
51
|
+
...keyframe,
|
|
52
|
+
percentage: roundTo3(timeAtProgress(runEase, keyframe.percentage)),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Grow a keyframe tween's range to a playhead outside it and add a keyframe there; every
|
|
57
|
+
* existing keyframe keeps its absolute time under `writeEase`, the run ease the write keeps. */
|
|
58
|
+
export function buildExtendedKeyframes(
|
|
59
|
+
anim: GsapAnimation,
|
|
60
|
+
currentTime: number,
|
|
61
|
+
position: Record<string, number>,
|
|
62
|
+
sourceDuration = resolveTweenDuration(anim),
|
|
63
|
+
writeEase = runEaseOf(anim),
|
|
64
|
+
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
|
65
|
+
const oldStart = resolveTweenStart(anim) ?? 0;
|
|
66
|
+
const oldDuration = sourceDuration;
|
|
67
|
+
const newStart = Math.min(oldStart, currentTime);
|
|
68
|
+
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
|
69
|
+
const newDuration = roundTo3(newEnd - newStart);
|
|
70
|
+
const oldRunEase = runEaseOf(anim);
|
|
71
|
+
const toPct = (absoluteTime: number) => {
|
|
72
|
+
if (newDuration <= 0) return 0;
|
|
73
|
+
const progress = progressAtTime(writeEase, ((absoluteTime - newStart) / newDuration) * 100);
|
|
74
|
+
return Math.max(0, Math.min(100, Math.round(progress * 10) / 10));
|
|
75
|
+
};
|
|
76
|
+
const stops = anim.keyframes?.keyframes ?? [];
|
|
77
|
+
const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
|
|
78
|
+
percentage: toPct(oldStart + (timeAtProgress(oldRunEase, stop.percentage) / 100) * oldDuration),
|
|
79
|
+
properties: stop.properties,
|
|
80
|
+
...(stop.ease ? { ease: stop.ease } : {}),
|
|
81
|
+
}));
|
|
82
|
+
const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
|
|
83
|
+
const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
|
|
84
|
+
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
/** commitGsapPositionFromDrag's refusal, decided without writing. */
|
|
42
88
|
export function gsapPositionFromDragOutcome(
|
|
43
89
|
selection: DomEditSelection,
|
|
@@ -87,15 +133,45 @@ export async function commitGsapPositionFromDrag(
|
|
|
87
133
|
};
|
|
88
134
|
|
|
89
135
|
if (anim.arcPath?.enabled) {
|
|
90
|
-
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
136
|
+
const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
|
|
137
|
+
const tweenStart = resolveTweenStart(anim);
|
|
138
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
139
|
+
if (
|
|
140
|
+
activeKeyframePct === null &&
|
|
141
|
+
tweenStart !== null &&
|
|
142
|
+
!isTimeWithinTween(currentTime, tweenStart, tweenDuration)
|
|
143
|
+
) {
|
|
144
|
+
const extended = buildExtendedKeyframes(
|
|
145
|
+
anim,
|
|
146
|
+
currentTime,
|
|
147
|
+
{ x: newX, y: newY },
|
|
148
|
+
tweenDuration,
|
|
149
|
+
"none",
|
|
150
|
+
);
|
|
151
|
+
await callbacks.commitMutation(
|
|
152
|
+
selection,
|
|
153
|
+
{
|
|
154
|
+
type: "replace-with-keyframes",
|
|
155
|
+
animationId: anim.id,
|
|
156
|
+
targetSelector: anim.targetSelector,
|
|
157
|
+
...extended,
|
|
158
|
+
ease: "none",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
label: "Move layer (new keyframe)",
|
|
162
|
+
keyframeAction: "add",
|
|
163
|
+
softReload: true,
|
|
164
|
+
beforeReload: restoreOffset,
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
return { status: "persisted" };
|
|
168
|
+
}
|
|
91
169
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
92
170
|
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const pointIndex = keyframes.findIndex(
|
|
97
|
-
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
|
98
|
-
);
|
|
171
|
+
// A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
|
|
172
|
+
// so landing a fraction of a percent off an authored waypoint updates that point
|
|
173
|
+
// instead of appending a new one.
|
|
174
|
+
const pointIndex = keyframes.findIndex((kf) => playsNear(anim, kf.percentage, pct));
|
|
99
175
|
if (pointIndex >= 0) {
|
|
100
176
|
await callbacks.commitMutation(
|
|
101
177
|
selection,
|
|
@@ -113,13 +189,10 @@ export async function commitGsapPositionFromDrag(
|
|
|
113
189
|
return { status: "persisted" };
|
|
114
190
|
}
|
|
115
191
|
|
|
116
|
-
const tweenStart = resolveTweenStart(anim);
|
|
117
|
-
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
|
|
118
|
-
// default here would collapse a duration-less arc's window on drag.
|
|
119
|
-
const tweenDuration = resolveEditableTweenDuration(anim, selection);
|
|
120
192
|
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
|
|
121
193
|
return { status: "persisted" };
|
|
122
|
-
const
|
|
194
|
+
const timePct = roundTo3(timeAtProgress(runEaseOf(anim), pct));
|
|
195
|
+
const temporalKeyframes = buildTemporalArcKeyframes(anim, timePct, { x: newX, y: newY });
|
|
123
196
|
await callbacks.commitMutation(
|
|
124
197
|
selection,
|
|
125
198
|
{
|
|
@@ -78,6 +78,56 @@ describe("non-string cache keys", () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
describe("replaceKeyframeCacheForFile re-reads", () => {
|
|
82
|
+
it("publishes nothing when a file re-reads to the same entries, and the change when one differs", () => {
|
|
83
|
+
const write = (x: number) =>
|
|
84
|
+
replaceKeyframeCacheForFile(
|
|
85
|
+
"comp.html",
|
|
86
|
+
new Map([
|
|
87
|
+
["box", { format: "percentage", keyframes: [{ percentage: 0, properties: { x } }] }],
|
|
88
|
+
]),
|
|
89
|
+
new Map([["box", [animWithKeyframes("box")]]]),
|
|
90
|
+
);
|
|
91
|
+
write(0);
|
|
92
|
+
let notified = 0;
|
|
93
|
+
const stop = usePlayerStore.subscribe(() => notified++);
|
|
94
|
+
write(0);
|
|
95
|
+
expect(notified).toBe(0);
|
|
96
|
+
write(5);
|
|
97
|
+
stop();
|
|
98
|
+
expect(notified).toBe(1);
|
|
99
|
+
expect(cache().get("comp.html#box")?.keyframes[0]?.properties).toEqual({ x: 5 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("leaves a sub-composition's entries alone when index.html re-reads", () => {
|
|
103
|
+
const one = (id: string) => new Map([[id, entry()]]);
|
|
104
|
+
const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
|
|
105
|
+
replaceKeyframeCacheForFile("comp.html", one("box"), anims("box"));
|
|
106
|
+
replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
|
|
107
|
+
let notified = 0;
|
|
108
|
+
const stop = usePlayerStore.subscribe(() => notified++);
|
|
109
|
+
replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
|
|
110
|
+
stop();
|
|
111
|
+
|
|
112
|
+
expect(notified).toBe(0);
|
|
113
|
+
for (const key of ["comp.html#box", "index.html#box", "box", "index.html#hero", "hero"]) {
|
|
114
|
+
expect(cache().has(key)).toBe(true);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("still drops index.html's own element that shares an id with a sub-composition", () => {
|
|
119
|
+
const one = (id: string) => new Map([[id, entry()]]);
|
|
120
|
+
const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
|
|
121
|
+
const own = { format: "percentage", keyframes: [{ percentage: 0, properties: { x: 9 } }] };
|
|
122
|
+
replaceKeyframeCacheForFile("comp.html", one("title"), anims("title"));
|
|
123
|
+
replaceKeyframeCacheForFile("index.html", new Map([["title", own]]), anims("title"));
|
|
124
|
+
replaceKeyframeCacheForFile("index.html", new Map(), new Map());
|
|
125
|
+
|
|
126
|
+
expect(cache().has("index.html#title")).toBe(false);
|
|
127
|
+
expect(cache().has("comp.html#title")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
81
131
|
describe("clearKeyframeCacheForElement", () => {
|
|
82
132
|
it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
|
|
83
133
|
seed("comp.html#box");
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
7
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
|
+
import { sameData } from "../utils/sameData";
|
|
8
9
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
9
10
|
import {
|
|
10
11
|
deduplicateKeyframes,
|
|
@@ -18,6 +19,14 @@ export interface KeyframeCacheDraft {
|
|
|
18
19
|
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// A rebuilt entry equal to the one shown keeps its object, so re-reading an unchanged file publishes nothing.
|
|
23
|
+
function keepEqualEntries<V>(before: ReadonlyMap<string, V>, draft: Map<string, V>): void {
|
|
24
|
+
for (const [key, value] of draft) {
|
|
25
|
+
const shown = before.get(key);
|
|
26
|
+
if (shown !== undefined && shown !== value && sameData(shown, value)) draft.set(key, shown);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
|
|
22
31
|
if (before.size !== after.size) return false;
|
|
23
32
|
for (const [key, value] of before) {
|
|
@@ -42,6 +51,8 @@ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void):
|
|
|
42
51
|
gsapAnimations: new Map(gsapAnimations),
|
|
43
52
|
};
|
|
44
53
|
edit(draft);
|
|
54
|
+
keepEqualEntries(keyframeCache, draft.keyframeCache);
|
|
55
|
+
keepEqualEntries(gsapAnimations, draft.gsapAnimations);
|
|
45
56
|
if (
|
|
46
57
|
sameEntries(keyframeCache, draft.keyframeCache) &&
|
|
47
58
|
sameEntries(gsapAnimations, draft.gsapAnimations)
|
|
@@ -175,8 +186,16 @@ function cachedElementIdsForFile(
|
|
|
175
186
|
const sfPrefix = `${sourceFile}#`;
|
|
176
187
|
const ids = new Set<string>();
|
|
177
188
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
|
178
|
-
if (
|
|
179
|
-
|
|
189
|
+
if (key.startsWith(sfPrefix)) ids.add(key.slice(sfPrefix.length));
|
|
190
|
+
}
|
|
191
|
+
if (sourceFile !== "index.html") return ids;
|
|
192
|
+
// Another file's element also writes the `index.html#id` fallback, as the very same entry object.
|
|
193
|
+
for (const [key, entry] of keyframeCache) {
|
|
194
|
+
const hash = key.indexOf("#");
|
|
195
|
+
const id = key.slice(hash + 1);
|
|
196
|
+
if (hash > 0 && !key.startsWith(sfPrefix) && keyframeCache.get(sfPrefix + id) === entry) {
|
|
197
|
+
ids.delete(id);
|
|
198
|
+
}
|
|
180
199
|
}
|
|
181
200
|
return ids;
|
|
182
201
|
}
|
|
@@ -88,9 +88,11 @@ function readMutationError(value: unknown, fallback: string): string {
|
|
|
88
88
|
return fallback;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
type GsapMutationRoute = "gsap-mutations" | "gsap-mutations-batch";
|
|
92
|
+
|
|
91
93
|
export function requestGsapMutation(
|
|
92
94
|
projectId: string,
|
|
93
|
-
route:
|
|
95
|
+
route: GsapMutationRoute,
|
|
94
96
|
filePath: string,
|
|
95
97
|
body: unknown,
|
|
96
98
|
): Promise<Response> {
|
|
@@ -104,23 +106,49 @@ export function requestGsapMutation(
|
|
|
104
106
|
);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
async function postOwnedGsapMutation(
|
|
110
|
+
route: GsapMutationRoute,
|
|
108
111
|
projectId: string,
|
|
109
112
|
filePath: string,
|
|
110
|
-
|
|
113
|
+
body: Record<string, unknown>,
|
|
111
114
|
fallback: string,
|
|
112
115
|
): Promise<GsapMutationStatus> {
|
|
113
116
|
let response: Response;
|
|
114
117
|
try {
|
|
115
|
-
response = await requestGsapMutation(projectId,
|
|
118
|
+
response = await requestGsapMutation(projectId, route, filePath, body);
|
|
116
119
|
} catch (error) {
|
|
117
120
|
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
118
121
|
cause: error,
|
|
119
122
|
});
|
|
120
123
|
}
|
|
121
|
-
const
|
|
124
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
122
125
|
if (!response.ok) {
|
|
123
|
-
throw new GsapPreviewConvergenceError(readMutationError(
|
|
126
|
+
throw new GsapPreviewConvergenceError(readMutationError(payload, fallback));
|
|
124
127
|
}
|
|
125
|
-
return readMutationStatus(
|
|
128
|
+
return readMutationStatus(payload);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function postGsapMutation(
|
|
132
|
+
projectId: string,
|
|
133
|
+
filePath: string,
|
|
134
|
+
mutation: Record<string, unknown>,
|
|
135
|
+
fallback: string,
|
|
136
|
+
): Promise<GsapMutationStatus> {
|
|
137
|
+
return postOwnedGsapMutation("gsap-mutations", projectId, filePath, mutation, fallback);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every mutation in one request: the file is parsed and written once, with one ownership pair. */
|
|
141
|
+
export function postGsapMutations(
|
|
142
|
+
projectId: string,
|
|
143
|
+
filePath: string,
|
|
144
|
+
mutations: readonly Record<string, unknown>[],
|
|
145
|
+
fallback: string,
|
|
146
|
+
): Promise<GsapMutationStatus> {
|
|
147
|
+
return postOwnedGsapMutation(
|
|
148
|
+
"gsap-mutations-batch",
|
|
149
|
+
projectId,
|
|
150
|
+
filePath,
|
|
151
|
+
{ mutations },
|
|
152
|
+
fallback,
|
|
153
|
+
);
|
|
126
154
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
3
|
+
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveKeyframeRetime } from "../components/editor/keyframeRetime";
|
|
6
|
+
import { absoluteToPercentageForAnimation } from "../utils/globalTimeCompiler";
|
|
7
|
+
import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
8
|
+
import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
|
|
9
|
+
import { keyframeEases } from "./gsapShared";
|
|
10
|
+
|
|
11
|
+
const script = (ease: string) =>
|
|
12
|
+
`const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "50%": { x: 600 }, "100%": { x: 1200 } }, duration: 2, ease: "${ease}" }, 1);`;
|
|
13
|
+
|
|
14
|
+
const percentages = (anim: GsapAnimation) => anim.keyframes!.keyframes.map((k) => k.percentage);
|
|
15
|
+
const distinctInRange = (pcts: number[]) =>
|
|
16
|
+
pcts.every((p, i) => p >= 0 && p <= 100 && (i === 0 || p > pcts[i - 1]!));
|
|
17
|
+
|
|
18
|
+
describe.each(["back.out(1.7)", "back.in(1.7)", "elastic.out(1, 0.3)", "steps(4)"])(
|
|
19
|
+
"an outer %s, which does not map time to progress one to one",
|
|
20
|
+
(ease) => {
|
|
21
|
+
const src = script(ease);
|
|
22
|
+
const anim = parseGsapScriptAcorn(src).animations[0]!;
|
|
23
|
+
|
|
24
|
+
it("adding a keyframe past the end keeps every keyframe, distinct and in range, after a reparse", () => {
|
|
25
|
+
const out = buildExtendedKeyframes(anim, 5, { x: 1500 });
|
|
26
|
+
const written = replaceTweenWithKeyframesInScript(src, anim.id, {
|
|
27
|
+
targetSelector: "#x",
|
|
28
|
+
...keyframeEases(anim),
|
|
29
|
+
...out,
|
|
30
|
+
})!;
|
|
31
|
+
const back = parseGsapScriptAcorn(written).animations[0]!;
|
|
32
|
+
expect(distinctInRange(percentages(back))).toBe(true);
|
|
33
|
+
expect(back.keyframes!.keyframes.map((k) => k.properties.x)).toEqual([0, 600, 1200, 1500]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("a keyframe added inside the tween lands within 0-100%", () => {
|
|
37
|
+
const pct = absoluteToPercentageForAnimation(1 + 0.7 * 2, anim)!;
|
|
38
|
+
expect(pct).toBeGreaterThanOrEqual(0);
|
|
39
|
+
expect(pct).toBeLessThanOrEqual(100);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("dragging the last keyframe later keeps every keyframe distinct and in range", () => {
|
|
43
|
+
const retime = resolveKeyframeRetime({
|
|
44
|
+
keyframes: anim.keyframes!.keyframes,
|
|
45
|
+
draggedTweenPct: 100,
|
|
46
|
+
tweenStart: 1,
|
|
47
|
+
tweenDuration: 2,
|
|
48
|
+
dropAbsTime: 4,
|
|
49
|
+
runEase: runEaseOf(anim),
|
|
50
|
+
});
|
|
51
|
+
expect(retime.kind).toBe("resize");
|
|
52
|
+
expect(distinctInRange(retime.pctRemap!.map((r) => r.to))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
it("extending a tween under steps(4) loses no keyframe", () => {
|
|
58
|
+
const src = `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "25%": { x: 60 }, "60%": { x: 120 }, "100%": { x: 30 } }, duration: 2, ease: "steps(4)" }, 1);`;
|
|
59
|
+
const anim = parseGsapScriptAcorn(src).animations[0]!;
|
|
60
|
+
const out = buildExtendedKeyframes(anim, 4, { x: 999 });
|
|
61
|
+
expect(distinctInRange(out.keyframes.map((k) => k.percentage))).toBe(true);
|
|
62
|
+
expect(out.keyframes.map((k) => k.properties.x)).toEqual([0, 60, 120, 30, 999]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("still times keyframes through an outer ease that rises one to one", () => {
|
|
66
|
+
expect(timeAtProgress("power2.in", 50)).toBeCloseTo(79.37, 1);
|
|
67
|
+
});
|
|
@@ -247,14 +247,13 @@ const BUILT_IN_EASES = [
|
|
|
247
247
|
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
248
248
|
const data = anim.keyframes;
|
|
249
249
|
const parts = tween?.timeline?.getChildren?.() ?? [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (ends.some((end) => !Number.isFinite(end))) return anim;
|
|
250
|
+
if (data?.format !== "object-array" || parts.length !== data.keyframes.length) return anim;
|
|
251
|
+
const ends = parts.map((part) => (part.startTime?.() ?? 0) + (part.duration?.() ?? 0));
|
|
252
|
+
const total = Math.max(...ends);
|
|
253
|
+
if (!(total > 0) || ends.some((end) => !Number.isFinite(end))) return anim;
|
|
255
254
|
const keyframes = data.keyframes.map((kf, i) => ({
|
|
256
255
|
...kf,
|
|
257
|
-
percentage: Math.round(ends[i]! * 100000) / 1000,
|
|
256
|
+
percentage: Math.round((ends[i]! / total) * 100000) / 1000,
|
|
258
257
|
}));
|
|
259
258
|
return { ...anim, keyframes: { ...data, keyframes } };
|
|
260
259
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
10
|
import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
|
|
11
|
+
import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
11
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
12
13
|
|
|
13
14
|
// fallow-ignore-next-line complexity
|
|
@@ -60,7 +61,7 @@ export function findGsapPositionAnimation(
|
|
|
60
61
|
|
|
61
62
|
function playheadProximity(a: GsapAnimation, currentTime: number): number {
|
|
62
63
|
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
63
|
-
const dur = a
|
|
64
|
+
const dur = resolveTweenDuration(a);
|
|
64
65
|
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
|
|
65
66
|
return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
|
|
66
67
|
}
|
|
@@ -71,7 +72,9 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
|
|
|
71
72
|
if (!(duration > 0)) return false;
|
|
72
73
|
const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
|
|
73
74
|
const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
|
|
74
|
-
|
|
75
|
+
const runEase = runEaseOf(anim);
|
|
76
|
+
if (anim.keyframes)
|
|
77
|
+
return anim.keyframes.keyframes.some((kf) => near(timeAtProgress(runEase, kf.percentage)));
|
|
75
78
|
if (anim.method === "from") return near(0);
|
|
76
79
|
return near(100) || (anim.method === "fromTo" && near(0));
|
|
77
80
|
}
|
|
@@ -582,6 +582,41 @@ describe("tryGsapDragIntercept — motion paths", () => {
|
|
|
582
582
|
);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
it("adds a node at a playhead past a duration-less arc, keeping the other nodes' times", async () => {
|
|
586
|
+
usePlayerStore.setState({
|
|
587
|
+
autoKeyframeEnabled: true,
|
|
588
|
+
activeKeyframePct: null,
|
|
589
|
+
currentTime: 15.9,
|
|
590
|
+
});
|
|
591
|
+
const durationless = { ...motionPathAnim, duration: undefined } as GsapAnimation;
|
|
592
|
+
const commitMutation = vi.fn();
|
|
593
|
+
await tryGsapDragIntercept(
|
|
594
|
+
selection,
|
|
595
|
+
{ x: -50, y: 30 },
|
|
596
|
+
[durationless],
|
|
597
|
+
fakeIframe("puck-b", [
|
|
598
|
+
{ ...liveTween, vars: { motionPath: { path: [] } }, duration: () => 0.5 },
|
|
599
|
+
]),
|
|
600
|
+
commitMutation,
|
|
601
|
+
);
|
|
602
|
+
// GSAP plays it 12.17-12.67 s; the nodes stay at 12.17, 12.42 and 12.67 s.
|
|
603
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
604
|
+
selection,
|
|
605
|
+
expect.objectContaining({
|
|
606
|
+
type: "replace-with-keyframes",
|
|
607
|
+
position: 12.17,
|
|
608
|
+
duration: 3.73,
|
|
609
|
+
keyframes: [
|
|
610
|
+
{ percentage: 0, properties: { x: -184, y: 326 } },
|
|
611
|
+
{ percentage: 6.7, properties: { x: 416, y: 804 } },
|
|
612
|
+
{ percentage: 13.4, properties: { x: 796, y: 237 } },
|
|
613
|
+
{ percentage: 100, properties: { x: -50, y: 30 } },
|
|
614
|
+
],
|
|
615
|
+
}),
|
|
616
|
+
expect.objectContaining({ label: "Move layer (new keyframe)", keyframeAction: "add" }),
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
|
|
585
620
|
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
|
586
621
|
const { commitMutation, handled } = await dragMotionPath(50);
|
|
587
622
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
readRuntimeKeyframes,
|
|
6
|
-
} from "./gsapRuntimeKeyframes";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { hasNonHoldTweenForElement, readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
3
|
+
import { arcPathFromMotionPathValue } from "./gsapRuntimeMotionPath";
|
|
4
|
+
import { tweensTargeting, withTweenIndex } from "./gsapRuntimeTweenIndex";
|
|
7
5
|
|
|
8
6
|
// Build a fake preview iframe whose runtime timeline holds the given child tweens
|
|
9
7
|
// and resolves `selector` to `el`.
|
|
@@ -261,3 +259,79 @@ describe("arcPathFromMotionPathValue", () => {
|
|
|
261
259
|
expect(arcPathFromMotionPathValue(null)).toBeUndefined();
|
|
262
260
|
});
|
|
263
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("readRuntimeKeyframes — a motion path runs GSAP's default tween ease", () => {
|
|
264
|
+
const el = { id: "arc" };
|
|
265
|
+
const arcTween = (ease?: string) => ({
|
|
266
|
+
targets: () => [el],
|
|
267
|
+
vars: {
|
|
268
|
+
motionPath: {
|
|
269
|
+
path: [
|
|
270
|
+
{ x: 0, y: 0 },
|
|
271
|
+
{ x: 100, y: -50 },
|
|
272
|
+
{ x: 200, y: 0 },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
ease,
|
|
276
|
+
},
|
|
277
|
+
duration: () => 2,
|
|
278
|
+
startTime: () => 0,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("reads power1.out when the tween sets no ease, and the authored ease otherwise", () => {
|
|
282
|
+
const read = (ease?: string) =>
|
|
283
|
+
readRuntimeKeyframes(fakeIframe(el, [arcTween(ease)]), "#arc")?.runEase;
|
|
284
|
+
expect(read()).toBe("power1.out");
|
|
285
|
+
expect(read("none")).toBe("none");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("withTweenIndex — many elements, one scan of the tweens", () => {
|
|
290
|
+
const ids = Array.from({ length: 20 }, (_, i) => `clip-${i}`);
|
|
291
|
+
const tweens = ids.map((id) => ({
|
|
292
|
+
targets: vi.fn(() => [{ id }]),
|
|
293
|
+
vars: { x: 10, duration: 1 },
|
|
294
|
+
duration: () => 1,
|
|
295
|
+
startTime: () => 0,
|
|
296
|
+
}));
|
|
297
|
+
const timeline = { getChildren: () => tweens };
|
|
298
|
+
const preview = {
|
|
299
|
+
contentWindow: { __timelines: { "index.html": timeline } },
|
|
300
|
+
contentDocument: { querySelector: (sel: string) => ({ id: sel.slice(1) }) },
|
|
301
|
+
} as unknown as HTMLIFrameElement;
|
|
302
|
+
const askAll = () => ids.map((id) => hasNonHoldTweenForElement(preview, `#${id}`));
|
|
303
|
+
|
|
304
|
+
it("answers the same as the plain scan", () => {
|
|
305
|
+
const plain = askAll();
|
|
306
|
+
expect(withTweenIndex(askAll)).toEqual(plain);
|
|
307
|
+
expect(plain.every(Boolean)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("reads each tween's targets once per pass instead of once per element", () => {
|
|
311
|
+
for (const tween of tweens) tween.targets.mockClear();
|
|
312
|
+
withTweenIndex(askAll);
|
|
313
|
+
expect(tweens.every((tween) => tween.targets.mock.calls.length === 1)).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("skips a timelines entry that is not an object", () => {
|
|
317
|
+
const odd = {
|
|
318
|
+
contentWindow: {
|
|
319
|
+
__timelines: { stray: null, label: "x", main: { getChildren: () => tweens } },
|
|
320
|
+
},
|
|
321
|
+
contentDocument: { querySelector: (sel: string) => ({ id: sel.slice(1) }) },
|
|
322
|
+
} as unknown as HTMLIFrameElement;
|
|
323
|
+
expect(withTweenIndex(() => hasNonHoldTweenForElement(odd, "#clip-3"))).toBe(true);
|
|
324
|
+
expect(withTweenIndex(() => hasNonHoldTweenForElement(odd, "#nobody"))).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns the plain scan's tweens, once each and in timeline order", () => {
|
|
328
|
+
const el = { id: "clip-2" } as unknown as Element;
|
|
329
|
+
const byElement = { targets: () => [el], vars: {}, duration: () => 1 };
|
|
330
|
+
const byId = { targets: () => [{ id: "clip-2" }], vars: {}, duration: () => 1 };
|
|
331
|
+
const both = { targets: () => [el, { id: "clip-2" }], vars: {}, duration: () => 1 };
|
|
332
|
+
const tl = { getChildren: () => [byId, byElement, both] };
|
|
333
|
+
const plain = tweensTargeting(tl, el);
|
|
334
|
+
expect(withTweenIndex(() => tweensTargeting(tl, el))).toEqual(plain);
|
|
335
|
+
expect(plain).toEqual([byId, byElement, both]);
|
|
336
|
+
});
|
|
337
|
+
});
|