@hyperframes/studio 0.8.126 → 0.8.128
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-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
- package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
- 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 +2184 -1745
- 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/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- 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/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +13 -0
- package/src/components/editor/motionPathGeometry.ts +4 -2
- 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 +41 -6
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapEditOutcome.ts +27 -2
- 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.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +27 -7
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +49 -35
- package/src/hooks/gsapShared.ts +35 -33
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
- package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
- package/src/hooks/gsapValueAtPlayhead.ts +49 -10
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- 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 +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
- 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/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- 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/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,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
|
+
});
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
|
|
9
9
|
* does that conversion itself when given a `clipById` map.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
|
+
import { timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
14
|
+
import { readMotionPathTween } from "./gsapRuntimeMotionPath";
|
|
13
15
|
import { roundTo3 } from "../utils/rounding";
|
|
16
|
+
import { matchesElement, tweensTargeting } from "./gsapRuntimeTweenIndex";
|
|
14
17
|
import { withParsedStart } from "./gsapParsedTween";
|
|
15
18
|
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
16
19
|
import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
|
|
@@ -43,11 +46,12 @@ export interface RuntimeTimeline {
|
|
|
43
46
|
to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
type Pct = { percentage: number; properties: Record<string, number | string
|
|
49
|
+
type Pct = { percentage: number; properties: Record<string, number | string>; step?: number };
|
|
47
50
|
export type ReadTween = {
|
|
48
51
|
keyframes: Pct[];
|
|
49
52
|
easeEach?: string;
|
|
50
53
|
arcPath?: ArcPathConfig;
|
|
54
|
+
runEase?: string;
|
|
51
55
|
/** GSAP's start values when the first keyframe is after 0%. */
|
|
52
56
|
start?: Record<string, number>;
|
|
53
57
|
};
|
|
@@ -92,10 +96,6 @@ function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTi
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
function isXY(p: unknown): p is { x: number; y: number } {
|
|
96
|
-
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
99
|
/**
|
|
100
100
|
* A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
|
|
101
101
|
* the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
|
|
@@ -107,31 +107,6 @@ function isZeroDurationSet(duration: number): boolean {
|
|
|
107
107
|
return !(duration > 0);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
111
|
-
function coordsFromMotionPath(mp: unknown): {
|
|
112
|
-
coords: Array<{ x: number; y: number }>;
|
|
113
|
-
curviness: number;
|
|
114
|
-
autoRotate: boolean | number;
|
|
115
|
-
isCubic: boolean;
|
|
116
|
-
} | null {
|
|
117
|
-
if (!mp || typeof mp !== "object") return null;
|
|
118
|
-
const obj = mp as Record<string, unknown>;
|
|
119
|
-
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
|
120
|
-
if (!Array.isArray(pathVal)) return null;
|
|
121
|
-
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
|
122
|
-
if (coords.length < 2) return null;
|
|
123
|
-
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
|
124
|
-
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
|
125
|
-
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Build an arcPath config from a live `vars.motionPath` value. */
|
|
129
|
-
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
|
130
|
-
const parsed = coordsFromMotionPath(mp);
|
|
131
|
-
if (!parsed) return undefined;
|
|
132
|
-
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
110
|
function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
|
136
111
|
const properties: Record<string, number | string> = {};
|
|
137
112
|
for (const [k, v] of Object.entries(vars)) {
|
|
@@ -146,36 +121,22 @@ function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
|
|
146
121
|
];
|
|
147
122
|
}
|
|
148
123
|
|
|
124
|
+
const stringOr = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
125
|
+
|
|
149
126
|
/** Tween-relative keyframes + optional arcPath for one live tween, or null. */
|
|
150
127
|
function readTween(vars: Record<string, unknown>): ReadTween | null {
|
|
128
|
+
const runEase = stringOr(vars.ease);
|
|
151
129
|
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
152
130
|
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const mp = coordsFromMotionPath(vars.motionPath);
|
|
156
|
-
if (mp) {
|
|
157
|
-
const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
|
158
|
-
if (shape) {
|
|
159
|
-
const n = shape.waypoints.length;
|
|
160
|
-
const keyframes = shape.waypoints.map((wp, i) => ({
|
|
161
|
-
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
|
162
|
-
properties: { x: wp.x, y: wp.y },
|
|
163
|
-
}));
|
|
164
|
-
return { keyframes, arcPath: shape.arcPath };
|
|
165
|
-
}
|
|
131
|
+
const keyframesEase = stringOr((vars.keyframes as { ease?: unknown }).ease);
|
|
132
|
+
if (parsed) return { ...parsed, runEase: keyframesEase ?? runEase };
|
|
166
133
|
}
|
|
134
|
+
const path = readMotionPathTween(vars, runEase);
|
|
135
|
+
if (path) return path;
|
|
167
136
|
const flat = flatTweenKeyframes(vars);
|
|
168
137
|
return flat ? { keyframes: flat } : null;
|
|
169
138
|
}
|
|
170
139
|
|
|
171
|
-
function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
|
172
|
-
if (!tween.targets) return false;
|
|
173
|
-
for (const t of tween.targets()) {
|
|
174
|
-
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
|
175
|
-
}
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
140
|
function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
|
|
180
141
|
const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
181
142
|
const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
@@ -438,10 +399,9 @@ export const GSAP_TRANSFORM_KEYS = new Set(
|
|
|
438
399
|
export function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
439
400
|
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
440
401
|
return Object.values(win?.__timelines ?? {}).some((tl) =>
|
|
441
|
-
(tl
|
|
402
|
+
tweensTargeting(tl, el).some(
|
|
442
403
|
(tween) =>
|
|
443
404
|
!!tween.vars &&
|
|
444
|
-
matchesElement(tween, el) &&
|
|
445
405
|
(channels.some((ch) => ch in tween.vars!) ||
|
|
446
406
|
keyframeVarsCarryChannel(tween.vars, channels)),
|
|
447
407
|
),
|
|
@@ -515,9 +475,8 @@ function hasNonHoldTween(
|
|
|
515
475
|
): boolean {
|
|
516
476
|
if (!timelines) return false;
|
|
517
477
|
for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
478
|
+
for (const tween of tweensTargeting(timelines[tlId], targetEl)) {
|
|
479
|
+
if (!tween.vars) continue;
|
|
521
480
|
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
522
481
|
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
523
482
|
if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
|
|
@@ -530,14 +489,15 @@ function hasNonHoldTween(
|
|
|
530
489
|
|
|
531
490
|
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
|
|
532
491
|
function toClipRelative(
|
|
533
|
-
|
|
492
|
+
read: ReadTween,
|
|
534
493
|
tweenStart: number,
|
|
535
494
|
tweenDuration: number,
|
|
536
495
|
clip: { start: number; duration: number } | undefined,
|
|
537
496
|
): Pct[] {
|
|
497
|
+
const { keyframes, runEase } = read;
|
|
538
498
|
if (!clip || clip.duration <= 0) return keyframes;
|
|
539
499
|
return keyframes.map((kf) => {
|
|
540
|
-
const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
|
|
500
|
+
const abs = toAbsoluteTime(tweenStart, tweenDuration, timeAtProgress(runEase, kf.percentage));
|
|
541
501
|
return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
|
|
542
502
|
});
|
|
543
503
|
}
|
|
@@ -549,7 +509,7 @@ function buildEntry(
|
|
|
549
509
|
clip: { start: number; duration: number } | undefined,
|
|
550
510
|
): RuntimeKeyframeEntry {
|
|
551
511
|
return {
|
|
552
|
-
keyframes: toClipRelative(read
|
|
512
|
+
keyframes: toClipRelative(read, start, duration, clip),
|
|
553
513
|
tweenStart: start,
|
|
554
514
|
tweenDuration: duration,
|
|
555
515
|
...(read.easeEach ? { easeEach: read.easeEach } : {}),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
2
|
+
import { MOTION_PATH_RUN_EASE } from "../utils/gsapKeyframeEases";
|
|
3
|
+
import type { ReadTween } from "./gsapRuntimeKeyframes";
|
|
4
|
+
|
|
5
|
+
function isXY(p: unknown): p is { x: number; y: number } {
|
|
6
|
+
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
10
|
+
function coordsFromMotionPath(mp: unknown): {
|
|
11
|
+
coords: Array<{ x: number; y: number }>;
|
|
12
|
+
curviness: number;
|
|
13
|
+
autoRotate: boolean | number;
|
|
14
|
+
isCubic: boolean;
|
|
15
|
+
} | null {
|
|
16
|
+
if (!mp || typeof mp !== "object") return null;
|
|
17
|
+
const obj = mp as Record<string, unknown>;
|
|
18
|
+
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
|
19
|
+
if (!Array.isArray(pathVal)) return null;
|
|
20
|
+
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
|
21
|
+
if (coords.length < 2) return null;
|
|
22
|
+
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
|
23
|
+
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
|
24
|
+
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build an arcPath config from a live `vars.motionPath` value. */
|
|
28
|
+
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
|
29
|
+
const parsed = coordsFromMotionPath(mp);
|
|
30
|
+
if (!parsed) return undefined;
|
|
31
|
+
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readMotionPathTween(
|
|
35
|
+
vars: Record<string, unknown>,
|
|
36
|
+
runEase?: string,
|
|
37
|
+
): ReadTween | null {
|
|
38
|
+
const mp = coordsFromMotionPath(vars.motionPath);
|
|
39
|
+
const shape = mp && buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
|
40
|
+
if (!shape) return null;
|
|
41
|
+
const n = shape.waypoints.length;
|
|
42
|
+
const keyframes = shape.waypoints.map((wp, i) => ({
|
|
43
|
+
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
|
44
|
+
properties: { x: wp.x, y: wp.y },
|
|
45
|
+
}));
|
|
46
|
+
return { keyframes, arcPath: shape.arcPath, runEase: runEase ?? MOTION_PATH_RUN_EASE };
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RuntimeTimeline, RuntimeTween } from "./gsapRuntimeKeyframes";
|
|
2
|
+
|
|
3
|
+
export function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
|
4
|
+
if (!tween.targets) return false;
|
|
5
|
+
for (const t of tween.targets()) {
|
|
6
|
+
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
|
7
|
+
}
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type TweenIndex = { byElement: Map<unknown, RuntimeTween[]>; byId: Map<string, RuntimeTween[]> };
|
|
12
|
+
let tweenIndexes: WeakMap<RuntimeTimeline, TweenIndex> | null = null;
|
|
13
|
+
|
|
14
|
+
/** Within `run`, finding the tweens on an element reads one index per timeline, not every tween. */
|
|
15
|
+
export function withTweenIndex<T>(run: () => T): T {
|
|
16
|
+
const outer = tweenIndexes;
|
|
17
|
+
tweenIndexes ??= new WeakMap();
|
|
18
|
+
try {
|
|
19
|
+
return run();
|
|
20
|
+
} finally {
|
|
21
|
+
tweenIndexes = outer;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function indexTweens(children: RuntimeTween[]): TweenIndex {
|
|
26
|
+
const index: TweenIndex = { byElement: new Map(), byId: new Map() };
|
|
27
|
+
const add = <K>(map: Map<K, RuntimeTween[]>, key: K, tween: RuntimeTween) => {
|
|
28
|
+
const list = map.get(key);
|
|
29
|
+
// A tween's targets are added together, so a repeat of its key is always the last entry.
|
|
30
|
+
if (!list) map.set(key, [tween]);
|
|
31
|
+
else if (list.at(-1) !== tween) list.push(tween);
|
|
32
|
+
};
|
|
33
|
+
for (const tween of children) {
|
|
34
|
+
for (const target of tween.targets?.() ?? []) {
|
|
35
|
+
add(index.byElement, target, tween);
|
|
36
|
+
const id = (target as Element).id;
|
|
37
|
+
if (id) add(index.byId, id, tween);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return index;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function tweensTargeting(
|
|
44
|
+
timeline: RuntimeTimeline | undefined,
|
|
45
|
+
el: Element,
|
|
46
|
+
): RuntimeTween[] {
|
|
47
|
+
if (!timeline || typeof timeline !== "object" || !tweenIndexes)
|
|
48
|
+
return (timeline?.getChildren?.(true) ?? []).filter((tween) => matchesElement(tween, el));
|
|
49
|
+
let index = tweenIndexes.get(timeline);
|
|
50
|
+
if (!index) tweenIndexes.set(timeline, (index = indexTweens(timeline.getChildren?.(true) ?? [])));
|
|
51
|
+
// The same rule as matchesElement: an element with an id matches every tween on that id.
|
|
52
|
+
return (el.id ? index.byId.get(el.id) : index.byElement.get(el)) ?? [];
|
|
53
|
+
}
|
|
@@ -1,33 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
3
|
import {
|
|
5
4
|
idFromSelector,
|
|
6
5
|
idSelector,
|
|
7
6
|
isInstantHold,
|
|
8
7
|
parsePercentageKeyframes,
|
|
9
8
|
resolveClipTimingBasis,
|
|
10
|
-
|
|
9
|
+
playsNear,
|
|
11
10
|
toClipKeyframes,
|
|
12
11
|
toClipPercentage,
|
|
13
12
|
} from "./gsapShared";
|
|
14
13
|
|
|
15
|
-
// Fixtures carry only the fields the function under test reads; the double-cast
|
|
16
|
-
// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
|
|
17
|
-
const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
|
|
18
|
-
|
|
19
|
-
describe("resolveEditableTweenDuration", () => {
|
|
20
|
-
const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
|
|
21
|
-
|
|
22
|
-
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
|
23
|
-
expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("keeps an explicitly-authored tween duration", () => {
|
|
27
|
-
expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
14
|
describe("isInstantHold", () => {
|
|
32
15
|
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
|
33
16
|
({ method, duration }) as unknown as GsapAnimation;
|
|
@@ -54,44 +37,49 @@ describe("parsePercentageKeyframes", () => {
|
|
|
54
37
|
]);
|
|
55
38
|
});
|
|
56
39
|
|
|
57
|
-
it("parses GSAP array-form keyframes as
|
|
58
|
-
//
|
|
59
|
-
//
|
|
40
|
+
it("parses GSAP array-form keyframes at each step's end, as the parser does", () => {
|
|
41
|
+
// A multi-point shuttle path authored as `keyframes: [...]` has no `N%` keys;
|
|
42
|
+
// step i of n ends at (i+1)/n.
|
|
60
43
|
const out = parsePercentageKeyframes([
|
|
61
44
|
{ x: 0, y: 0 },
|
|
62
45
|
{ x: 520, y: 120 },
|
|
63
46
|
{ x: 1040, y: 0 },
|
|
64
47
|
{ x: 1480, y: 160 },
|
|
65
48
|
] as unknown as Record<string, unknown>);
|
|
66
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
49
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([25, 50, 75, 100]);
|
|
50
|
+
expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
|
|
67
51
|
expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
|
|
52
|
+
const timed = parsePercentageKeyframes([
|
|
53
|
+
{ x: 0, duration: 1 },
|
|
54
|
+
{ x: 50, duration: 3 },
|
|
55
|
+
] as unknown as Record<string, unknown>);
|
|
56
|
+
expect(timed?.keyframes.map((k) => k.percentage)).toEqual([25, 100]);
|
|
68
57
|
});
|
|
69
58
|
|
|
70
|
-
it("strips a per-entry ease without shifting the
|
|
59
|
+
it("strips a per-entry ease without shifting the step spacing of the others", () => {
|
|
71
60
|
// GSAP positions array keyframes by array index, so a `{ ease }` carried on an
|
|
72
61
|
// entry is a segment ease (skipped as a property) — it must not change where
|
|
73
|
-
// the surrounding keyframes land. 3 entries →
|
|
62
|
+
// the surrounding keyframes land. 3 entries → 33.3 / 66.7 / 100, even though the
|
|
74
63
|
// middle entry also carries an ease.
|
|
75
64
|
const out = parsePercentageKeyframes([
|
|
76
65
|
{ x: 0 },
|
|
77
66
|
{ x: 100, ease: "power2.in" },
|
|
78
67
|
{ x: 200 },
|
|
79
68
|
] as unknown as Record<string, unknown>);
|
|
80
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
69
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 66.7, 100]);
|
|
81
70
|
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
|
|
82
71
|
});
|
|
83
72
|
|
|
84
|
-
it("keeps
|
|
73
|
+
it("keeps the step spacing when an interior array slot has no animatable prop", () => {
|
|
85
74
|
// A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
|
|
86
75
|
// still an array slot GSAP allocates a position to — so the remaining entries
|
|
87
|
-
// keep their
|
|
88
|
-
// 0/100 collapsed onto a 2-entry spacing.
|
|
76
|
+
// keep their 3-slot percentages (33.3 and 100), not a 2-entry spacing.
|
|
89
77
|
const out = parsePercentageKeyframes([
|
|
90
78
|
{ x: 0 },
|
|
91
79
|
{ ease: "power2.in" },
|
|
92
80
|
{ x: 200 },
|
|
93
81
|
] as unknown as Record<string, unknown>);
|
|
94
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
82
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 100]);
|
|
95
83
|
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
|
|
96
84
|
});
|
|
97
85
|
|
|
@@ -156,12 +144,38 @@ describe("toClipKeyframes", () => {
|
|
|
156
144
|
resolvedStart: 0,
|
|
157
145
|
} as unknown as GsapAnimation;
|
|
158
146
|
|
|
159
|
-
|
|
160
|
-
// (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
|
|
161
|
-
// basis put the end keyframe at 25% of a 4s clip instead of 100%.
|
|
162
|
-
it("spans the clip when the tween has no duration", () => {
|
|
147
|
+
it("places a duration-less tween's keyframes where GSAP plays them, not across the clip", () => {
|
|
163
148
|
const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
|
|
164
|
-
expect(rows.map((row) => row.percentage)).toEqual([0,
|
|
149
|
+
expect(rows.map((row) => row.percentage)).toEqual([0, 12.5]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("matches a keyframe by the time it plays, not its warped progress", () => {
|
|
153
|
+
const eased = {
|
|
154
|
+
...durationless,
|
|
155
|
+
duration: 1,
|
|
156
|
+
ease: "power2.in",
|
|
157
|
+
keyframes: { format: "percentage", keyframes: [] },
|
|
158
|
+
} as unknown as GsapAnimation;
|
|
159
|
+
// At 20% of the time power2.in (cubic) shows 0.8% progress: close in progress, far in time.
|
|
160
|
+
expect(playsNear(eased, 0, 0.8)).toBe(false);
|
|
161
|
+
expect(playsNear(eased, 50, 50.5)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("places a keyframe where an outer ease makes GSAP reach it", () => {
|
|
165
|
+
const eased = {
|
|
166
|
+
...durationless,
|
|
167
|
+
duration: 1,
|
|
168
|
+
ease: "power2.in",
|
|
169
|
+
keyframes: { format: "percentage", keyframes: [] },
|
|
170
|
+
} as unknown as GsapAnimation;
|
|
171
|
+
const rows = toClipKeyframes(
|
|
172
|
+
[{ percentage: 0 }, { percentage: 50 }, { percentage: 100 }],
|
|
173
|
+
eased,
|
|
174
|
+
0,
|
|
175
|
+
1,
|
|
176
|
+
);
|
|
177
|
+
// GSAP's power2 is cubic: it reaches 50% at the cube root of 0.5.
|
|
178
|
+
expect(rows.map((row) => row.percentage)).toEqual([0, 79.37, 100]);
|
|
165
179
|
});
|
|
166
180
|
|
|
167
181
|
it("keeps the tween percentage and the animation identity on every row", () => {
|
|
@@ -275,7 +289,7 @@ describe("sub-composition keyframe percentages", () => {
|
|
|
275
289
|
|
|
276
290
|
it("puts a tween on the host's first frame at 0%, never below zero", () => {
|
|
277
291
|
// A clip-relative percentage can never be negative; this one cached -12.
|
|
278
|
-
expect(percentages(inner(0))).toEqual([0,
|
|
292
|
+
expect(percentages(inner(0))).toEqual([0, 4]);
|
|
279
293
|
});
|
|
280
294
|
|
|
281
295
|
it("puts the last tween's end keyframe at 100%", () => {
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
7
7
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
|
+
import { getObjectArrayKeyframeTiming } from "@hyperframes/parsers/gsap-parser";
|
|
9
|
+
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
8
10
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
9
11
|
import {
|
|
10
12
|
absoluteToPercentage,
|
|
@@ -88,6 +90,13 @@ const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
|
|
|
88
90
|
*/
|
|
89
91
|
export const KEYFRAME_PCT_MATCH = 1;
|
|
90
92
|
|
|
93
|
+
/** Whether the keyframe at `percentage` plays within KEYFRAME_PCT_MATCH of the time where GSAP shows `progress`. */
|
|
94
|
+
export function playsNear(anim: GsapAnimation, percentage: number, progress: number): boolean {
|
|
95
|
+
const runEase = runEaseOf(anim);
|
|
96
|
+
const gap = timeAtProgress(runEase, percentage) - timeAtProgress(runEase, progress);
|
|
97
|
+
return Math.abs(gap) <= KEYFRAME_PCT_MATCH;
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
export function idSelector(id: string): string {
|
|
92
101
|
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
|
|
93
102
|
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
|
|
@@ -332,22 +341,6 @@ export function tweenTargetsElement(
|
|
|
332
341
|
|
|
333
342
|
// ── Percentage computation ────────────────────────────────────────────────────
|
|
334
343
|
|
|
335
|
-
/**
|
|
336
|
-
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
|
337
|
-
* duration-less tween across its owning clip, so mutations must use that same
|
|
338
|
-
* duration instead of silently falling back to GSAP's 0.5s default.
|
|
339
|
-
*/
|
|
340
|
-
export function resolveEditableTweenDuration(
|
|
341
|
-
animation: GsapAnimation,
|
|
342
|
-
selection: DomEditSelection,
|
|
343
|
-
): number {
|
|
344
|
-
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
|
345
|
-
return resolveTweenDuration(
|
|
346
|
-
animation,
|
|
347
|
-
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
344
|
/**
|
|
352
345
|
* Compute the current playback percentage within an element's animation range.
|
|
353
346
|
* Uses the animation's resolved timing if available, otherwise falls back to
|
|
@@ -360,10 +353,13 @@ export function computeElementPercentage(
|
|
|
360
353
|
): number {
|
|
361
354
|
if (animation) {
|
|
362
355
|
const start = resolveTweenStart(animation);
|
|
363
|
-
const duration =
|
|
356
|
+
const duration = resolveTweenDuration(animation);
|
|
364
357
|
if (duration <= 0) return 0;
|
|
365
358
|
if (start !== null) {
|
|
366
|
-
return
|
|
359
|
+
return progressAtTime(
|
|
360
|
+
runEaseOf(animation),
|
|
361
|
+
absoluteToPercentage(currentTime, start, duration),
|
|
362
|
+
);
|
|
367
363
|
}
|
|
368
364
|
}
|
|
369
365
|
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
|
@@ -399,7 +395,11 @@ export function queryIframeElement(
|
|
|
399
395
|
// ── Keyframe parsing ──────────────────────────────────────────────────────────
|
|
400
396
|
|
|
401
397
|
export interface ParsedPercentageKeyframes {
|
|
402
|
-
keyframes: Array<{
|
|
398
|
+
keyframes: Array<{
|
|
399
|
+
percentage: number;
|
|
400
|
+
properties: Record<string, number | string>;
|
|
401
|
+
step?: number;
|
|
402
|
+
}>;
|
|
403
403
|
easeEach?: string;
|
|
404
404
|
}
|
|
405
405
|
|
|
@@ -424,13 +424,9 @@ export function parsePercentageKeyframes(
|
|
|
424
424
|
const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
|
|
425
425
|
let easeEach: string | undefined;
|
|
426
426
|
|
|
427
|
-
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` —
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
// studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
|
|
431
|
-
// Index spacing counts EVERY array slot, including a degenerate entry that
|
|
432
|
-
// contributes no animatable prop (it's still a slot GSAP allocates a position
|
|
433
|
-
// to), so dropping such an entry from the output below must NOT shift the others.
|
|
427
|
+
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — place each step at
|
|
428
|
+
// its cumulative end, the parser's rule (getObjectArrayKeyframeTiming). Spacing counts
|
|
429
|
+
// EVERY array slot, so dropping an entry with no animatable prop must NOT shift the others.
|
|
434
430
|
// A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
|
|
435
431
|
// a property; there is no array-form `easeEach` (that's an object-form sibling key).
|
|
436
432
|
// (The object form further down uses explicit "0%" keys instead.) Without this
|
|
@@ -438,11 +434,16 @@ export function parsePercentageKeyframes(
|
|
|
438
434
|
// motion path.
|
|
439
435
|
if (Array.isArray(kfObj)) {
|
|
440
436
|
const steps = kfObj as unknown[];
|
|
437
|
+
const timing = getObjectArrayKeyframeTiming(
|
|
438
|
+
steps.map((entry) => (entry as { duration?: unknown } | null)?.duration),
|
|
439
|
+
);
|
|
440
|
+
if (!timing) return null;
|
|
441
441
|
steps.forEach((entry, i) => {
|
|
442
442
|
if (!entry || typeof entry !== "object") return;
|
|
443
|
-
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
|
444
443
|
const properties = collectAnimatableKeyframeProperties(entry);
|
|
445
|
-
if (Object.keys(properties).length > 0)
|
|
444
|
+
if (Object.keys(properties).length > 0) {
|
|
445
|
+
keyframes.push({ percentage: timing.percentages[i]!, properties, step: i });
|
|
446
|
+
}
|
|
446
447
|
});
|
|
447
448
|
return keyframes.length > 0 ? { keyframes } : null;
|
|
448
449
|
}
|
|
@@ -571,14 +572,15 @@ export function toClipKeyframes<T extends { percentage: number }>(
|
|
|
571
572
|
}
|
|
572
573
|
> {
|
|
573
574
|
const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
574
|
-
|
|
575
|
-
// (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
|
|
576
|
-
// percentage no editor agreed with.
|
|
577
|
-
const tweenDuration = anim.duration ?? clipDuration;
|
|
575
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
578
576
|
return source.map((keyframe) => ({
|
|
579
577
|
...keyframe,
|
|
580
578
|
percentage: toClipPercentage(
|
|
581
|
-
toAbsoluteTime(
|
|
579
|
+
toAbsoluteTime(
|
|
580
|
+
tweenStart,
|
|
581
|
+
tweenDuration,
|
|
582
|
+
timeAtProgress(runEaseOf(anim), keyframe.percentage),
|
|
583
|
+
),
|
|
582
584
|
clipStart,
|
|
583
585
|
clipDuration,
|
|
584
586
|
keyframe.percentage,
|