@hyperframes/studio 0.8.115 → 0.8.116
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-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
- package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +104 -22
- package/dist/index.html +1 -1
- package/dist/index.js +1131 -1044
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +8 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +5 -0
- package/src/hooks/useGsapAwareGroupMove.ts +16 -16
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -10
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { findParsedTween, parsedImplicitEndValue } from "./gsapParsedTween";
|
|
5
|
+
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
|
|
6
|
+
|
|
7
|
+
type Parsed = Parameters<typeof parsedImplicitEndValue>[0];
|
|
8
|
+
const el = document.createElement("div");
|
|
9
|
+
const part = (start: number, ends?: Record<string, [number, number]>) =>
|
|
10
|
+
liveTween(el, { start, duration: 1, vars: { x: 0 }, ends });
|
|
11
|
+
const keyframed = (parts: unknown[]) =>
|
|
12
|
+
liveTween(el, { start: 0, duration: 2, vars: { keyframes: [] } }, { parts }) as Parsed;
|
|
13
|
+
|
|
14
|
+
describe("parsedImplicitEndValue", () => {
|
|
15
|
+
it("reads a flat tween's own start and end", () => {
|
|
16
|
+
const value = parsedImplicitEndValue(part(0, { x: [-60, 0] }) as Parsed);
|
|
17
|
+
expect([value("x", "start"), value("x", "end")]).toEqual([-60, 0]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reads a keyframed tween's start from its first part and its end from its last", () => {
|
|
21
|
+
const value = parsedImplicitEndValue(
|
|
22
|
+
keyframed([part(0, { x: [0, 50] }), part(1, { x: [50, 120] })]),
|
|
23
|
+
);
|
|
24
|
+
expect([value("x", "start"), value("x", "end")]).toEqual([0, 120]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("refuses when the part nearest that end is not initialised, rather than read an earlier one", () => {
|
|
28
|
+
const value = parsedImplicitEndValue(keyframed([part(0, { x: [0, 50] }), part(1)]));
|
|
29
|
+
expect(value("x", "end")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("has no value without a parsed tween", () => {
|
|
33
|
+
expect(parsedImplicitEndValue(null)("x", "start")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("findParsedTween", () => {
|
|
38
|
+
afterEach(() => document.body.replaceChildren());
|
|
39
|
+
|
|
40
|
+
it("takes the flat tween an edit names, not a keyframed tween at the same start", () => {
|
|
41
|
+
const fade = liveTween(el, { start: 0, duration: 2, vars: { keyframes: {}, ease: "none" } });
|
|
42
|
+
const slide = liveTween(el, { start: 0, duration: 2, vars: { x: 300 } });
|
|
43
|
+
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 0, duration: 2 });
|
|
44
|
+
expect(findParsedTween(previewWith(el, [fade, slide]), el, edit)).toBe(slide);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parses a to() tween the playhead has not reached, from its authored start", () => {
|
|
48
|
+
const box = document.body.appendChild(document.createElement("div"));
|
|
49
|
+
box.id = "box";
|
|
50
|
+
const timeline = gsap.timeline({ paused: true }).to(box, { x: 300, duration: 1 }, 2);
|
|
51
|
+
timeline.seek(0.5);
|
|
52
|
+
gsap.set(box, { x: 40 });
|
|
53
|
+
const iframe = { contentWindow: { __timelines: { main: timeline }, gsap } };
|
|
54
|
+
const edit = tween({ method: "to", properties: { x: 300 }, resolvedStart: 2, duration: 1 });
|
|
55
|
+
|
|
56
|
+
const parsed = findParsedTween(iframe as unknown as HTMLIFrameElement, box, edit);
|
|
57
|
+
|
|
58
|
+
expect(parsedImplicitEndValue(parsed)("x", "start")).toBe(0);
|
|
59
|
+
expect([gsap.getProperty(box, "x"), timeline.time()]).toEqual([40, 0.5]);
|
|
60
|
+
timeline.kill();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { elementTargets } from "../utils/elementGsap";
|
|
3
|
+
import { resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
4
|
+
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
|
|
5
|
+
|
|
6
|
+
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
|
|
7
|
+
interface PropTween {
|
|
8
|
+
p?: string;
|
|
9
|
+
s?: number;
|
|
10
|
+
c?: number;
|
|
11
|
+
d?: { _pt?: PropTween };
|
|
12
|
+
_next?: PropTween;
|
|
13
|
+
}
|
|
14
|
+
interface ParsedTween {
|
|
15
|
+
_pt?: PropTween;
|
|
16
|
+
_from?: boolean;
|
|
17
|
+
_initted?: boolean;
|
|
18
|
+
vars?: Record<string, unknown>;
|
|
19
|
+
parent?: {
|
|
20
|
+
vars?: { defaults?: { ease?: unknown } };
|
|
21
|
+
time?: () => number;
|
|
22
|
+
seek?: (time: number, suppressEvents?: boolean) => unknown;
|
|
23
|
+
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
|
|
24
|
+
};
|
|
25
|
+
timeline?: { getChildren?: () => ParsedTween[] };
|
|
26
|
+
targets?: () => unknown[];
|
|
27
|
+
startTime?: () => number;
|
|
28
|
+
duration?: () => number;
|
|
29
|
+
}
|
|
30
|
+
interface GsapWindow {
|
|
31
|
+
gsap?: {
|
|
32
|
+
defaults?: () => { ease?: unknown };
|
|
33
|
+
getProperty?: (target: Element, prop: string) => unknown;
|
|
34
|
+
set?: (target: Element, vars: Record<string, unknown>) => void;
|
|
35
|
+
parseEase?: (name: string) => unknown;
|
|
36
|
+
};
|
|
37
|
+
__timelines?: Record<string, { getChildren?: (nested: boolean) => ParsedTween[] }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `scale` parses into the two longhands, which always share a start and end for a `scale` tween.
|
|
41
|
+
const PARSED_NAME: Record<string, string> = { scale: "scaleX", rotate: "rotation" };
|
|
42
|
+
|
|
43
|
+
function findPropTween(pt: PropTween | undefined, prop: string): PropTween | null {
|
|
44
|
+
for (let node = pt; node; node = node._next) {
|
|
45
|
+
if (node.p === prop && typeof node.s === "number" && typeof node.c === "number") return node;
|
|
46
|
+
const nested = findPropTween(node.d?._pt, prop);
|
|
47
|
+
if (nested) return nested;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `[start, end]` of `prop` in one initialised tween; a from() tween runs its pair backwards. */
|
|
53
|
+
function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
|
|
54
|
+
const pt = findPropTween(tween._pt, PARSED_NAME[prop] ?? prop);
|
|
55
|
+
if (!pt) return null;
|
|
56
|
+
const pair: [number, number] = [pt.s!, pt.s! + pt.c!];
|
|
57
|
+
return tween._from ? [pair[1], pair[0]] : pair;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
|
|
61
|
+
// fallow-ignore-next-line complexity
|
|
62
|
+
export function findParsedTween(
|
|
63
|
+
iframe: HTMLIFrameElement | null,
|
|
64
|
+
element: Element,
|
|
65
|
+
anim: GsapAnimation,
|
|
66
|
+
): ParsedTween | null {
|
|
67
|
+
const win = iframe?.contentWindow as GsapWindow | null;
|
|
68
|
+
const start = resolveTweenStart(anim);
|
|
69
|
+
if (!win?.__timelines || start == null) return null;
|
|
70
|
+
const steps = anim.keyframes?.keyframes.map((kf) => kf.properties) ?? [anim.properties];
|
|
71
|
+
const props = [...new Set(steps.flatMap((step) => Object.keys(step)))];
|
|
72
|
+
const keyframed = Boolean(anim.keyframes);
|
|
73
|
+
for (const timeline of Object.values(win.__timelines)) {
|
|
74
|
+
for (const tween of timeline?.getChildren?.(true) ?? []) {
|
|
75
|
+
const targets = (tween.targets?.() ?? []) as Array<{ id?: unknown }>;
|
|
76
|
+
if (!targets.includes(element) && !targets.some((t) => element.id && t.id === element.id))
|
|
77
|
+
continue;
|
|
78
|
+
if (!startsAt(tween, start)) continue;
|
|
79
|
+
const vars = tween.vars ?? {};
|
|
80
|
+
const carries = keyframed ? "keyframes" in vars : props.some((p) => p in vars);
|
|
81
|
+
if (!carries || !((tween.duration?.() ?? 0) > 0)) continue;
|
|
82
|
+
parseUnplayed(win, element, tween, props);
|
|
83
|
+
return tween;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** GSAP's startTime() counts the tween's `delay`; the file's parse does not. */
|
|
90
|
+
function startsAt(tween: ParsedTween, start: number): boolean {
|
|
91
|
+
const delay = Number(tween.vars?.delay) || 0;
|
|
92
|
+
return Math.abs((tween.startTime?.() ?? Number.NaN) - delay - start) <= 1e-3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `anim` timed as GSAP runs it: its start with any delay, a duration it leaves to GSAP filled in. */
|
|
96
|
+
export function withLiveTiming(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
97
|
+
const start = tween?.startTime?.();
|
|
98
|
+
return {
|
|
99
|
+
...anim,
|
|
100
|
+
...(Number.isFinite(start) && { resolvedStart: start }),
|
|
101
|
+
...(anim.duration == null && tween && { duration: tween.duration?.() }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** GSAP parses a to() tween only when the playhead first passes it, and a soft reload resets that.
|
|
106
|
+
* Play its timeline from the tween's start (so earlier tweens set its start value) to its end and
|
|
107
|
+
* back, its channels cleared first; then restore every styled element's attributes and GSAP cache. */
|
|
108
|
+
function parseUnplayed(win: GsapWindow, element: Element, tween: ParsedTween, props: string[]) {
|
|
109
|
+
const seek = !isParsed(tween) && seekable(win, tween);
|
|
110
|
+
if (!seek) return;
|
|
111
|
+
const { parent, gsap } = seek;
|
|
112
|
+
const before = layerStates(tween, element);
|
|
113
|
+
const now = parent.time();
|
|
114
|
+
const start = tween.startTime?.() ?? 0;
|
|
115
|
+
gsap.set(element, { clearProps: props.join(",") });
|
|
116
|
+
// Re-read now, so GSAP rebuilds its transform cache from the cleared element before the seek.
|
|
117
|
+
for (const p of props) gsap.getProperty(element, p);
|
|
118
|
+
try {
|
|
119
|
+
parent.seek(start, true);
|
|
120
|
+
parent.seek(start + (tween.duration?.() ?? 0), true);
|
|
121
|
+
} finally {
|
|
122
|
+
parent.seek(now, true);
|
|
123
|
+
restore(before);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isParsed(tween: ParsedTween): boolean {
|
|
128
|
+
const parts = tween.timeline?.getChildren?.() ?? [];
|
|
129
|
+
return Boolean(tween._initted) && parts.every((part) => part._initted);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type GsapTools = {
|
|
133
|
+
getProperty: (el: Element, p: string) => unknown;
|
|
134
|
+
set: (el: Element, v: Record<string, unknown>) => void;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function seekable(win: GsapWindow, tween: ParsedTween) {
|
|
138
|
+
const { parent } = tween;
|
|
139
|
+
const gsap = win.gsap;
|
|
140
|
+
if (!parent?.seek || !parent.time || !gsap?.getProperty || !gsap.set) return null;
|
|
141
|
+
return {
|
|
142
|
+
parent: { seek: parent.seek.bind(parent), time: parent.time.bind(parent) },
|
|
143
|
+
gsap: { getProperty: gsap.getProperty.bind(gsap), set: gsap.set.bind(gsap) } as GsapTools,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type Layer = Element & { _gsap?: Record<string, unknown> };
|
|
148
|
+
|
|
149
|
+
function layerStates(tween: ParsedTween, element: Element) {
|
|
150
|
+
const layers = new Set<Layer>([element]);
|
|
151
|
+
for (const child of tween.parent?.getChildren?.(true, true, false) ?? [])
|
|
152
|
+
for (const target of elementTargets(child)) layers.add(target);
|
|
153
|
+
return [...layers].map((layer) => ({
|
|
154
|
+
layer,
|
|
155
|
+
attributes: new Map([...layer.attributes].map((a) => [a.name, a.value])),
|
|
156
|
+
cache: layer._gsap && { ...layer._gsap },
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restore(states: ReturnType<typeof layerStates>) {
|
|
161
|
+
for (const { layer, attributes, cache } of states) {
|
|
162
|
+
restoreAttributes(layer, attributes);
|
|
163
|
+
if (cache && layer._gsap) restoreCache(layer._gsap, cache);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function restoreAttributes(layer: Element, attributes: Map<string, string>) {
|
|
168
|
+
for (const { name } of [...layer.attributes])
|
|
169
|
+
if (!attributes.has(name)) layer.removeAttribute(name);
|
|
170
|
+
for (const [name, value] of attributes)
|
|
171
|
+
if (layer.getAttribute(name) !== value) layer.setAttribute(name, value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function restoreCache(live: Record<string, unknown>, cache: Record<string, unknown>) {
|
|
175
|
+
for (const key of Object.keys(live)) if (!(key in cache)) delete live[key];
|
|
176
|
+
Object.assign(live, cache);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Start and end values from GSAP's own parse of the tween, as loaded from the file. Null when
|
|
180
|
+
* GSAP has not initialised that part of the tween yet: the caller refuses rather than guess. */
|
|
181
|
+
export function parsedImplicitEndValue(tween: ParsedTween | null): ImplicitEndValue {
|
|
182
|
+
return (prop, end) => {
|
|
183
|
+
if (!tween) return null;
|
|
184
|
+
for (const part of partsFrom(tween, end)) {
|
|
185
|
+
const pair = endsIn(part, prop);
|
|
186
|
+
if (pair) return end === "start" ? pair[0] : pair[1];
|
|
187
|
+
// The nearest part animating `prop` is not initialised: an earlier one would be a wrong value.
|
|
188
|
+
if (prop in (part.vars ?? {})) return null;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A keyframed tween's parts, nearest `end` first; a flat tween is its own one part. */
|
|
195
|
+
function partsFrom(tween: ParsedTween, end: "start" | "end"): ParsedTween[] {
|
|
196
|
+
const parts = tween.timeline?.getChildren?.() ?? [];
|
|
197
|
+
if (parts.length === 0) return [tween];
|
|
198
|
+
return end === "start" ? parts : [...parts].reverse();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The ease GSAP resolved for a flat tween that authors none: its timeline's default, else GSAP's. */
|
|
202
|
+
export function parsedTweenEase(iframe: HTMLIFrameElement | null, tween: ParsedTween | null) {
|
|
203
|
+
if (!tween) return null;
|
|
204
|
+
const win = iframe?.contentWindow as GsapWindow | null;
|
|
205
|
+
const ease =
|
|
206
|
+
tween.vars?.ease ?? tween.parent?.vars?.defaults?.ease ?? win?.gsap?.defaults?.().ease;
|
|
207
|
+
if (typeof ease === "string") return ease;
|
|
208
|
+
const named = (name: string) => win?.gsap?.parseEase?.(name) === ease;
|
|
209
|
+
return typeof ease === "function" ? (BUILT_IN_EASES.find(named) ?? null) : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// GSAP holds a resolved ease, its own default included, as the function parseEase returns.
|
|
213
|
+
const BUILT_IN_EASES = [
|
|
214
|
+
"none",
|
|
215
|
+
...[
|
|
216
|
+
"power1",
|
|
217
|
+
"power2",
|
|
218
|
+
"power3",
|
|
219
|
+
"power4",
|
|
220
|
+
"sine",
|
|
221
|
+
"expo",
|
|
222
|
+
"circ",
|
|
223
|
+
"back",
|
|
224
|
+
"elastic",
|
|
225
|
+
"bounce",
|
|
226
|
+
].flatMap((e) => [`${e}.in`, `${e}.out`, `${e}.inOut`]),
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/** An array of keyframe steps at the exact percentages GSAP times them; the parse rounds them. */
|
|
230
|
+
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
231
|
+
const data = anim.keyframes;
|
|
232
|
+
const parts = tween?.timeline?.getChildren?.() ?? [];
|
|
233
|
+
const total = tween?.duration?.() ?? 0;
|
|
234
|
+
if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
|
|
235
|
+
return anim;
|
|
236
|
+
const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
|
|
237
|
+
if (ends.some((end) => !Number.isFinite(end))) return anim;
|
|
238
|
+
const keyframes = data.keyframes.map((kf, i) => ({
|
|
239
|
+
...kf,
|
|
240
|
+
percentage: Math.round(ends[i]! * 100000) / 1000,
|
|
241
|
+
}));
|
|
242
|
+
return { ...anim, keyframes: { ...data, keyframes } };
|
|
243
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { tween } from "./gsapParsedTween.test-helpers";
|
|
5
|
+
import { findGsapPositionAnimation, pickClosestToPlayhead } from "./gsapPositionDetection";
|
|
6
|
+
|
|
7
|
+
afterEach(() => usePlayerStore.setState({ currentTime: 0 }));
|
|
8
|
+
|
|
9
|
+
const slide = (
|
|
10
|
+
id: string,
|
|
11
|
+
resolvedStart: number,
|
|
12
|
+
duration: number,
|
|
13
|
+
fields: Partial<GsapAnimation> = {},
|
|
14
|
+
) => tween({ id, method: "to", properties: { x: 10 }, resolvedStart, duration, ...fields });
|
|
15
|
+
const keyed = (...percentages: number[]) => ({
|
|
16
|
+
keyframes: {
|
|
17
|
+
format: "object-array" as const,
|
|
18
|
+
keyframes: percentages.map((percentage) => ({ percentage, properties: { x: percentage } })),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("pickClosestToPlayhead", () => {
|
|
23
|
+
it.each([
|
|
24
|
+
["the one spanning the playhead", 1.5, [slide("a", 0, 1), slide("b", 1, 1)], "b"],
|
|
25
|
+
["the nearer end when none spans it", 5, [slide("a", 0, 1), slide("b", 2, 2)], "b"],
|
|
26
|
+
[
|
|
27
|
+
"the one whose file states the playhead's value",
|
|
28
|
+
1,
|
|
29
|
+
[slide("a", 0, 1), slide("b", 1, 1)],
|
|
30
|
+
"a",
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
"the later one when both state it",
|
|
34
|
+
1,
|
|
35
|
+
[slide("a", 0, 2, keyed(0, 50, 100)), slide("b", 0.5, 1, keyed(0, 50, 100))],
|
|
36
|
+
"b",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"the one with more keyframes on a tie",
|
|
40
|
+
5,
|
|
41
|
+
[slide("a", 0, 1, keyed(0, 100)), slide("b", 0, 1, keyed(0, 50, 100))],
|
|
42
|
+
"b",
|
|
43
|
+
],
|
|
44
|
+
])("picks %s", (_, time, anims, id) => {
|
|
45
|
+
usePlayerStore.setState({ currentTime: time });
|
|
46
|
+
expect(pickClosestToPlayhead(anims)?.id).toBe(id);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("findGsapPositionAnimation", () => {
|
|
51
|
+
it.each([
|
|
52
|
+
[
|
|
53
|
+
"the tween spanning the playhead",
|
|
54
|
+
1.5,
|
|
55
|
+
undefined,
|
|
56
|
+
[slide("a", 0, 1, keyed(0, 100)), slide("b", 1, 1)],
|
|
57
|
+
"b",
|
|
58
|
+
],
|
|
59
|
+
["the nearer tween", 9, undefined, [slide("a", 0, 1), slide("b", 6, 1)], "b"],
|
|
60
|
+
[
|
|
61
|
+
"the selected target's tween",
|
|
62
|
+
0.5,
|
|
63
|
+
"#box",
|
|
64
|
+
[slide("a", 0, 1, { targetSelector: "#card", ...keyed(0, 100) }), slide("b", 0, 1)],
|
|
65
|
+
"b",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"a tween on one target over a shared one",
|
|
69
|
+
0.5,
|
|
70
|
+
undefined,
|
|
71
|
+
[slide("a", 0, 1, { targetSelector: "#box, #card" }), slide("b", 0, 1)],
|
|
72
|
+
"b",
|
|
73
|
+
],
|
|
74
|
+
])("prefers %s", (_, time, selector, anims, id) => {
|
|
75
|
+
usePlayerStore.setState({ currentTime: time });
|
|
76
|
+
expect(findGsapPositionAnimation(anims, selector)?.id).toBe(id);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
|
-
import { getIframeGsap, queryIframeElement } from "./gsapShared";
|
|
10
|
+
import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
|
|
11
11
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
12
12
|
|
|
13
13
|
// fallow-ignore-next-line complexity
|
|
@@ -47,52 +47,71 @@ export function findGsapPositionAnimation(
|
|
|
47
47
|
if (animations.length === 0) return null;
|
|
48
48
|
const currentTime = usePlayerStore.getState().currentTime;
|
|
49
49
|
|
|
50
|
-
const scored = animations
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
else if (a.targetSelector.includes(",")) score -= 5;
|
|
58
|
-
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
59
|
-
const dur = a.duration ?? 0;
|
|
60
|
-
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
|
|
61
|
-
else
|
|
62
|
-
score -= Math.round(
|
|
63
|
-
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
|
|
64
|
-
);
|
|
65
|
-
return { anim: a, score };
|
|
66
|
-
});
|
|
50
|
+
const scored = animations.filter(animHasPosition).map((a) => {
|
|
51
|
+
let score = 0;
|
|
52
|
+
if (a.keyframes) score += 5;
|
|
53
|
+
if (selector && a.targetSelector === selector) score += 8;
|
|
54
|
+
else if (a.targetSelector.includes(",")) score -= 5;
|
|
55
|
+
return { anim: a, score: score + playheadProximity(a, currentTime) };
|
|
56
|
+
});
|
|
67
57
|
scored.sort((a, b) => b.score - a.score);
|
|
68
|
-
return scored[0]?.anim ??
|
|
58
|
+
return scored[0]?.anim ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function playheadProximity(a: GsapAnimation, currentTime: number): number {
|
|
62
|
+
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
63
|
+
const dur = a.duration ?? 0;
|
|
64
|
+
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
|
|
65
|
+
return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether the file states `anim`'s value at `time`; a tween's implicit start is GSAP's current value. */
|
|
69
|
+
function statesValueAt(anim: GsapAnimation, time: number): boolean {
|
|
70
|
+
const duration = resolveTweenDuration(anim);
|
|
71
|
+
if (!(duration > 0)) return false;
|
|
72
|
+
const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
|
|
73
|
+
const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
|
|
74
|
+
if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
|
|
75
|
+
if (anim.method === "from") return near(0);
|
|
76
|
+
return near(100) || (anim.method === "fromTo" && near(0));
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/**
|
|
72
80
|
* From a set of candidate tweens, pick the one whose time range is closest to
|
|
73
81
|
* the current playhead. A tween that *contains* the playhead wins outright;
|
|
74
82
|
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
|
|
75
|
-
* extends) the 4s tween, not the 1.5s one.
|
|
76
|
-
*
|
|
83
|
+
* extends) the 4s tween, not the 1.5s one. Ties: the one whose file states a value at the
|
|
84
|
+
* playhead (the later one, which renders, if both do), then most keyframes.
|
|
77
85
|
*/
|
|
78
86
|
// fallow-ignore-next-line complexity
|
|
79
87
|
export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
|
|
80
88
|
if (anims.length <= 1) return anims[0] ?? null;
|
|
81
89
|
const ct = usePlayerStore.getState().currentTime;
|
|
82
90
|
return anims.reduce((best, a) => {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
|
|
86
|
-
const bestS = resolveTweenStart(best) ?? 0;
|
|
87
|
-
const bestE = bestS + resolveTweenDuration(best);
|
|
88
|
-
const bestDist =
|
|
89
|
-
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
|
|
91
|
+
const dist = playheadDistance(a, ct);
|
|
92
|
+
const bestDist = playheadDistance(best, ct);
|
|
90
93
|
if (dist < bestDist) return a;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
return a;
|
|
94
|
+
const stated = dist === 0 && bestDist === 0 ? statedValueWinner(a, best, ct) : null;
|
|
95
|
+
if (stated) return stated;
|
|
96
|
+
if (dist === bestDist && keyframeCount(a) > keyframeCount(best)) return a;
|
|
96
97
|
return best;
|
|
97
98
|
});
|
|
98
99
|
}
|
|
100
|
+
|
|
101
|
+
function playheadDistance(anim: GsapAnimation, time: number): number {
|
|
102
|
+
const s = resolveTweenStart(anim) ?? 0;
|
|
103
|
+
const e = s + resolveTweenDuration(anim);
|
|
104
|
+
return time >= s && time <= e ? 0 : Math.min(Math.abs(time - s), Math.abs(time - e));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function statedValueWinner(a: GsapAnimation, b: GsapAnimation, time: number) {
|
|
108
|
+
const [mine, theirs] = [statesValueAt(a, time), statesValueAt(b, time)];
|
|
109
|
+
if (mine !== theirs) return mine ? a : b;
|
|
110
|
+
const [s, bS] = [resolveTweenStart(a) ?? 0, resolveTweenStart(b) ?? 0];
|
|
111
|
+
if (mine && s !== bS) return s > bS ? a : b;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function keyframeCount(anim: GsapAnimation): number {
|
|
116
|
+
return anim.keyframes?.keyframes.length ?? 0;
|
|
117
|
+
}
|
|
@@ -148,17 +148,18 @@ function positionAnimation(
|
|
|
148
148
|
} as unknown as GsapAnimation;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/** Every x/y a run wrote, in commit order. */
|
|
151
|
+
/** Every x/y a run wrote AT THE PLAYHEAD (percentage 0 here), in commit order. */
|
|
152
152
|
function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
|
|
153
153
|
const written: Array<{ x?: number; y?: number }> = [];
|
|
154
154
|
for (const call of calls) {
|
|
155
155
|
const mutation = call[1] as {
|
|
156
156
|
properties?: Record<string, number>;
|
|
157
|
-
keyframes?: Array<{ properties: Record<string, number> }>;
|
|
157
|
+
keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
|
|
158
158
|
x?: number;
|
|
159
159
|
y?: number;
|
|
160
160
|
};
|
|
161
|
-
const
|
|
161
|
+
const atPlayhead = (mutation.keyframes ?? []).filter((k) => k.percentage === 0);
|
|
162
|
+
const sources = [mutation.properties, ...atPlayhead.map((k) => k.properties)];
|
|
162
163
|
if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
|
|
163
164
|
for (const source of sources) {
|
|
164
165
|
if (source && (source.x != null || source.y != null))
|
|
@@ -23,17 +23,13 @@
|
|
|
23
23
|
* the way through. A sweep across rotations is what tells us it is.
|
|
24
24
|
*/
|
|
25
25
|
import { afterEach, expect, it, vi } from "vitest";
|
|
26
|
-
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
27
26
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
28
27
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
29
28
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
29
|
+
import { elTween, resetGsapEditState } from "./gsapParsedTween.test-helpers";
|
|
30
30
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
31
31
|
|
|
32
|
-
afterEach(
|
|
33
|
-
vi.restoreAllMocks();
|
|
34
|
-
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
35
|
-
document.body.innerHTML = "";
|
|
36
|
-
});
|
|
32
|
+
afterEach(resetGsapEditState);
|
|
37
33
|
|
|
38
34
|
const LAYOUT = { left: 120, top: 520 };
|
|
39
35
|
|
|
@@ -57,20 +53,6 @@ function renderRect(pose: Pose, rotationDeg: number) {
|
|
|
57
53
|
|
|
58
54
|
type Props = Record<string, number>;
|
|
59
55
|
|
|
60
|
-
function tween(id: string, properties: Props, duration: number): GsapAnimation {
|
|
61
|
-
return {
|
|
62
|
-
id,
|
|
63
|
-
targetSelector: "#el",
|
|
64
|
-
propertyGroup: classifyTweenPropertyGroup(properties),
|
|
65
|
-
method: "to",
|
|
66
|
-
properties,
|
|
67
|
-
position: 0,
|
|
68
|
-
resolvedStart: 0,
|
|
69
|
-
duration,
|
|
70
|
-
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
|
|
71
|
-
} as unknown as GsapAnimation;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
56
|
interface Case {
|
|
75
57
|
name: string;
|
|
76
58
|
/** The element's untransformed CSS box. */
|
|
@@ -99,18 +81,24 @@ const ROTATIONS = [0, -8, 45, -47, 90, 180];
|
|
|
99
81
|
* the size routes here however it looks from the animation list.
|
|
100
82
|
*/
|
|
101
83
|
const ROUTES = {
|
|
102
|
-
"scale tween": {
|
|
84
|
+
"scale tween": {
|
|
85
|
+
animations: () => [elTween("#el-scale", { scale: 1 }, 2, "none")],
|
|
86
|
+
settles: true,
|
|
87
|
+
},
|
|
103
88
|
"scale longhands": {
|
|
104
|
-
animations: () => [
|
|
89
|
+
animations: () => [elTween("#el-scale", { scaleX: 1, scaleY: 1 }, 2, "none")],
|
|
105
90
|
settles: true,
|
|
106
91
|
},
|
|
107
|
-
"scale instant hold": {
|
|
92
|
+
"scale instant hold": {
|
|
93
|
+
animations: () => [elTween("#el-scale", { scale: 1 }, 0, "none")],
|
|
94
|
+
settles: false,
|
|
95
|
+
},
|
|
108
96
|
"size tween": {
|
|
109
|
-
animations: () => [
|
|
97
|
+
animations: () => [elTween("#el-size", { width: 630, height: 408 }, 2, "none")],
|
|
110
98
|
settles: false,
|
|
111
99
|
},
|
|
112
100
|
"size instant hold": {
|
|
113
|
-
animations: () => [
|
|
101
|
+
animations: () => [elTween("#el-size", { width: 630, height: 408 }, 0, "none")],
|
|
114
102
|
settles: false,
|
|
115
103
|
},
|
|
116
104
|
} as const;
|