@hyperframes/studio 0.6.101 → 0.6.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-DvttAtOD.js → index-BZKngETE.js} +1 -1
- package/dist/assets/{index-CTiqZ7XQ.js → index-BzjItfjX.js} +98 -98
- package/dist/assets/{index-CQ3n6Y9q.js → index-C0vMHtMH.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
- package/src/hooks/gsapScriptCommitTypes.ts +3 -0
- package/src/hooks/useGsapAnimationOps.ts +38 -26
- package/src/hooks/useGsapScriptCommits.ts +17 -1
- package/src/hooks/useGsapTweenCache.ts +10 -12
- package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
- package/src/utils/sdkShadow.test.ts +131 -1
- package/src/utils/sdkShadow.ts +46 -16
- package/src/utils/sdkShadowGsapFidelity.ts +208 -0
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BzjItfjX.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.102",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"@phosphor-icons/react": "^2.1.10",
|
|
34
34
|
"bpm-detective": "^2.0.5",
|
|
35
35
|
"mediabunny": "^1.45.3",
|
|
36
|
-
"@hyperframes/core": "0.6.
|
|
37
|
-
"@hyperframes/player": "0.6.
|
|
38
|
-
"@hyperframes/sdk": "0.6.
|
|
36
|
+
"@hyperframes/core": "0.6.102",
|
|
37
|
+
"@hyperframes/player": "0.6.102",
|
|
38
|
+
"@hyperframes/sdk": "0.6.102"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/react": "19",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"vite": "^6.4.2",
|
|
50
50
|
"vitest": "^3.2.4",
|
|
51
51
|
"zustand": "^5.0.0",
|
|
52
|
-
"@hyperframes/producer": "0.6.
|
|
52
|
+
"@hyperframes/producer": "0.6.102"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "19",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
4
|
+
import {
|
|
5
|
+
clearKeyframeCacheForElement,
|
|
6
|
+
clearKeyframeCacheForFile,
|
|
7
|
+
updateKeyframeCacheFromParsed,
|
|
8
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
9
|
+
|
|
10
|
+
const entry = (): KeyframeCacheEntry => ({
|
|
11
|
+
format: "percentage",
|
|
12
|
+
keyframes: [{ percentage: 0, properties: { x: 0 } }],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const seed = (key: string) => usePlayerStore.getState().setKeyframeCache(key, entry());
|
|
16
|
+
const cache = () => usePlayerStore.getState().keyframeCache;
|
|
17
|
+
|
|
18
|
+
const animWithKeyframes = (id: string): GsapAnimation => ({
|
|
19
|
+
id,
|
|
20
|
+
targetSelector: `#${id}`,
|
|
21
|
+
method: "to",
|
|
22
|
+
position: 0,
|
|
23
|
+
properties: {},
|
|
24
|
+
duration: 1,
|
|
25
|
+
resolvedStart: 0,
|
|
26
|
+
propertyGroup: "position",
|
|
27
|
+
keyframes: { format: "percentage", keyframes: [{ percentage: 50, properties: { x: 100 } }] },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("clearKeyframeCacheForElement", () => {
|
|
35
|
+
it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
|
|
36
|
+
seed("comp.html#box");
|
|
37
|
+
seed("index.html#box");
|
|
38
|
+
seed("box");
|
|
39
|
+
|
|
40
|
+
clearKeyframeCacheForElement("comp.html", "box");
|
|
41
|
+
|
|
42
|
+
expect(cache().has("comp.html#box")).toBe(false);
|
|
43
|
+
expect(cache().has("index.html#box")).toBe(false);
|
|
44
|
+
// The bare key is what PropertyPanel's keyframe nav reads (element.id), so
|
|
45
|
+
// it must be cleared too, not just the prefixed variants.
|
|
46
|
+
expect(cache().has("box")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("drops the prefixed and bare key for an index.html source", () => {
|
|
50
|
+
seed("index.html#hero");
|
|
51
|
+
seed("hero");
|
|
52
|
+
|
|
53
|
+
clearKeyframeCacheForElement("index.html", "hero");
|
|
54
|
+
|
|
55
|
+
expect(cache().has("index.html#hero")).toBe(false);
|
|
56
|
+
expect(cache().has("hero")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves other elements' keys untouched", () => {
|
|
60
|
+
seed("index.html#box");
|
|
61
|
+
seed("box");
|
|
62
|
+
seed("index.html#other");
|
|
63
|
+
seed("other");
|
|
64
|
+
|
|
65
|
+
clearKeyframeCacheForElement("index.html", "box");
|
|
66
|
+
|
|
67
|
+
expect(cache().has("index.html#other")).toBe(true);
|
|
68
|
+
expect(cache().has("other")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("clearKeyframeCacheForFile", () => {
|
|
73
|
+
it("clears the prefixed, fallback, and bare keys for every element of the file", () => {
|
|
74
|
+
seed("comp.html#a");
|
|
75
|
+
seed("index.html#a");
|
|
76
|
+
seed("a");
|
|
77
|
+
seed("comp.html#b");
|
|
78
|
+
seed("b");
|
|
79
|
+
|
|
80
|
+
clearKeyframeCacheForFile("comp.html");
|
|
81
|
+
|
|
82
|
+
for (const key of ["comp.html#a", "index.html#a", "a", "comp.html#b", "b"]) {
|
|
83
|
+
expect(cache().has(key)).toBe(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("leaves entries that belong to a different source file", () => {
|
|
88
|
+
seed("comp.html#a");
|
|
89
|
+
seed("a");
|
|
90
|
+
seed("other.html#z");
|
|
91
|
+
seed("z");
|
|
92
|
+
|
|
93
|
+
clearKeyframeCacheForFile("comp.html");
|
|
94
|
+
|
|
95
|
+
expect(cache().has("other.html#z")).toBe(true);
|
|
96
|
+
expect(cache().has("z")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("updateKeyframeCacheFromParsed", () => {
|
|
101
|
+
it("clears the bare key when the selected element no longer has keyframes", () => {
|
|
102
|
+
// Element previously had keyframes, so a bare entry exists (writes set both).
|
|
103
|
+
seed("index.html#box");
|
|
104
|
+
seed("box");
|
|
105
|
+
|
|
106
|
+
// A mutation leaves #box without any keyframes in the parsed animations.
|
|
107
|
+
updateKeyframeCacheFromParsed([], "index.html", "box", {});
|
|
108
|
+
|
|
109
|
+
expect(cache().has("index.html#box")).toBe(false);
|
|
110
|
+
// Without the bare-key clear this assertion fails: the stale entry survives
|
|
111
|
+
// and PropertyPanel keeps rendering the removed keyframes.
|
|
112
|
+
expect(cache().has("box")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("still writes the bare key for elements that have keyframes", () => {
|
|
116
|
+
updateKeyframeCacheFromParsed([animWithKeyframes("hero")], "index.html", "hero", {});
|
|
117
|
+
|
|
118
|
+
expect(cache().has("index.html#hero")).toBe(true);
|
|
119
|
+
expect(cache().has("hero")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -67,8 +67,54 @@ export function updateKeyframeCacheFromParsed(
|
|
|
67
67
|
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
|
68
68
|
selectionId;
|
|
69
69
|
if (targetId && !idsWithKeyframes.has(targetId)) {
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
clearKeyframeCacheForElement(targetPath, targetId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Clear every keyframe-cache key variant written for an element: the
|
|
76
|
+
* source-prefixed key, the index.html fallback, and the bare element id.
|
|
77
|
+
* Writes set all three (see updateKeyframeCacheFromParsed and
|
|
78
|
+
* usePopulateKeyframeCacheForFile). PropertyPanel's keyframe nav reads the bare
|
|
79
|
+
* id directly (`element.id`), and other consumers (timeline diamonds, the
|
|
80
|
+
* preview overlay) fall back to the bare id when an element has no
|
|
81
|
+
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
|
|
82
|
+
* bare entry behind and those readers keep showing keyframes the element no
|
|
83
|
+
* longer has. Each delete is guarded by `has` so an absent key doesn't allocate
|
|
84
|
+
* a new cache map and re-render every subscriber.
|
|
85
|
+
*/
|
|
86
|
+
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
|
87
|
+
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
|
88
|
+
const keys =
|
|
89
|
+
sourceFile === "index.html"
|
|
90
|
+
? [`index.html#${elementId}`, elementId]
|
|
91
|
+
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear every cached element of `sourceFile` before a full re-scan repopulates
|
|
99
|
+
* it. Collects the element ids that currently have a prefixed or index.html
|
|
100
|
+
* fallback key for the file and drops each through clearKeyframeCacheForElement
|
|
101
|
+
* so the bare key goes too — an element whose keyframes were removed (and so is
|
|
102
|
+
* absent from the re-scan) leaves no stale bare entry behind.
|
|
103
|
+
*/
|
|
104
|
+
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
|
105
|
+
const { keyframeCache } = usePlayerStore.getState();
|
|
106
|
+
const sfPrefix = `${sourceFile}#`;
|
|
107
|
+
const fallbackPrefix = "index.html#";
|
|
108
|
+
const ids = new Set<string>();
|
|
109
|
+
for (const key of keyframeCache.keys()) {
|
|
110
|
+
const matchesFile =
|
|
111
|
+
key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
|
|
112
|
+
if (!matchesFile) continue;
|
|
113
|
+
const hashIdx = key.indexOf("#");
|
|
114
|
+
if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
|
|
115
|
+
}
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
clearKeyframeCacheForElement(sourceFile, id);
|
|
72
118
|
}
|
|
73
119
|
}
|
|
74
120
|
|
|
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
|
5
6
|
|
|
6
7
|
export interface MutationResult {
|
|
7
8
|
ok: boolean;
|
|
@@ -18,6 +19,8 @@ export interface CommitMutationOptions {
|
|
|
18
19
|
softReload?: boolean;
|
|
19
20
|
skipReload?: boolean;
|
|
20
21
|
beforeReload?: () => void;
|
|
22
|
+
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
|
23
|
+
shadowGsapOp?: ShadowGsapOp;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export type CommitMutation = (
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import type { Composition
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { roundTo3 } from "../utils/rounding";
|
|
5
|
-
import { runShadowGsapTween } from "../utils/sdkShadow";
|
|
5
|
+
import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
|
|
6
6
|
import {
|
|
7
7
|
assignGsapTargetAutoIdIfNeeded,
|
|
8
8
|
ensureElementAddressable,
|
|
@@ -33,27 +33,34 @@ export function useGsapAnimationOps({
|
|
|
33
33
|
animationId: string,
|
|
34
34
|
updates: { duration?: number; ease?: string; position?: number },
|
|
35
35
|
) => {
|
|
36
|
+
// Shadow op (server animationId shares the SDK id-space): existence via
|
|
37
|
+
// runShadowGsapTween (live session) + value fidelity via the chokepoint.
|
|
38
|
+
const shadowGsapOp: ShadowGsapOp = {
|
|
39
|
+
kind: "set",
|
|
40
|
+
animationId,
|
|
41
|
+
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
|
42
|
+
};
|
|
36
43
|
commitMutationSafely(
|
|
37
44
|
selection,
|
|
38
45
|
{ type: "update-meta", animationId, updates },
|
|
39
|
-
{
|
|
40
|
-
label: "Edit GSAP animation",
|
|
41
|
-
coalesceKey: `gsap:${animationId}:meta`,
|
|
42
|
-
},
|
|
46
|
+
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
|
|
43
47
|
);
|
|
48
|
+
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
44
49
|
},
|
|
45
|
-
[commitMutationSafely],
|
|
50
|
+
[commitMutationSafely, sdkSession],
|
|
46
51
|
);
|
|
47
52
|
|
|
48
53
|
const deleteGsapAnimation = useCallback(
|
|
49
54
|
(selection: DomEditSelection, animationId: string) => {
|
|
55
|
+
const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
|
|
50
56
|
commitMutationSafely(
|
|
51
57
|
selection,
|
|
52
58
|
{ type: "delete", animationId, stripStudioEdits: true },
|
|
53
|
-
{ label: "Delete GSAP animation" },
|
|
59
|
+
{ label: "Delete GSAP animation", shadowGsapOp },
|
|
54
60
|
);
|
|
61
|
+
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
55
62
|
},
|
|
56
|
-
[commitMutationSafely],
|
|
63
|
+
[commitMutationSafely, sdkSession],
|
|
57
64
|
);
|
|
58
65
|
|
|
59
66
|
const deleteAllForSelector = useCallback(
|
|
@@ -103,6 +110,26 @@ export function useGsapAnimationOps({
|
|
|
103
110
|
fromTo: { x: 0, y: 0, opacity: 1 },
|
|
104
111
|
};
|
|
105
112
|
|
|
113
|
+
// Shadow op (server stays authoritative). "set" has no SDK method, so it
|
|
114
|
+
// is not shadowed; otherwise: existence via runShadowGsapTween (live) +
|
|
115
|
+
// value fidelity via the chokepoint (shadowGsapOp in options).
|
|
116
|
+
const shadowGsapOp: ShadowGsapOp | undefined =
|
|
117
|
+
selection.hfId && method !== "set"
|
|
118
|
+
? {
|
|
119
|
+
kind: "add",
|
|
120
|
+
target: selection.hfId,
|
|
121
|
+
tween: {
|
|
122
|
+
method,
|
|
123
|
+
position,
|
|
124
|
+
duration,
|
|
125
|
+
ease: "power2.out",
|
|
126
|
+
...(method === "fromTo"
|
|
127
|
+
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
|
128
|
+
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
: undefined;
|
|
132
|
+
|
|
106
133
|
await commitMutation(
|
|
107
134
|
selection,
|
|
108
135
|
{
|
|
@@ -115,25 +142,10 @@ export function useGsapAnimationOps({
|
|
|
115
142
|
properties: toDefaults[method] ?? { opacity: 1 },
|
|
116
143
|
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
|
117
144
|
},
|
|
118
|
-
{ label: `Add GSAP ${method} animation
|
|
145
|
+
{ label: `Add GSAP ${method} animation`, shadowGsapOp },
|
|
119
146
|
);
|
|
120
147
|
|
|
121
|
-
|
|
122
|
-
// authoritative). "set" has no SDK method, so it is not shadowed.
|
|
123
|
-
// ponytail: only add is shadowed — delete/update key on the server's
|
|
124
|
-
// animationId, which doesn't resolve in the SDK's independent id-space.
|
|
125
|
-
if (sdkSession && selection.hfId && method !== "set") {
|
|
126
|
-
const tween: GsapTweenSpec = {
|
|
127
|
-
method,
|
|
128
|
-
position,
|
|
129
|
-
duration,
|
|
130
|
-
ease: "power2.out",
|
|
131
|
-
...(method === "fromTo"
|
|
132
|
-
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
|
133
|
-
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
|
134
|
-
};
|
|
135
|
-
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
|
|
136
|
-
}
|
|
148
|
+
if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
137
149
|
},
|
|
138
150
|
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
|
139
151
|
);
|
|
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|
|
2
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
|
+
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
|
|
5
6
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
6
7
|
import {
|
|
7
8
|
GsapMutationHttpError,
|
|
@@ -67,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
67
68
|
}
|
|
68
69
|
if (result.changed === false) return;
|
|
69
70
|
domEditSaveTimestampRef.current = Date.now();
|
|
71
|
+
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
72
|
+
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
73
|
+
// Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via
|
|
74
|
+
// useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce,
|
|
75
|
+
// useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up.
|
|
76
|
+
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
77
|
+
const fidelityArgs = resolveGsapFidelityArgs(
|
|
78
|
+
sdkSession,
|
|
79
|
+
options.shadowGsapOp,
|
|
80
|
+
result.before,
|
|
81
|
+
result.scriptText,
|
|
82
|
+
);
|
|
83
|
+
if (fidelityArgs) {
|
|
84
|
+
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
85
|
+
}
|
|
70
86
|
if (result.before != null && result.after != null) {
|
|
71
87
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
72
88
|
}
|
|
@@ -80,7 +96,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
80
96
|
reloadPreview();
|
|
81
97
|
}
|
|
82
98
|
onCacheInvalidate();
|
|
83
|
-
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]);
|
|
99
|
+
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
|
|
84
100
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
85
101
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
86
102
|
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
|
@@ -3,6 +3,10 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/
|
|
|
3
3
|
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
5
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
6
|
+
import {
|
|
7
|
+
clearKeyframeCacheForElement,
|
|
8
|
+
clearKeyframeCacheForFile,
|
|
9
|
+
} from "./gsapKeyframeCacheHelpers";
|
|
6
10
|
import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
|
|
7
11
|
|
|
8
12
|
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
|
@@ -301,10 +305,7 @@ export function useGsapAnimationsForElement(
|
|
|
301
305
|
if (kf.easeEach) easeEach = kf.easeEach;
|
|
302
306
|
}
|
|
303
307
|
if (allKeyframes.length === 0) {
|
|
304
|
-
|
|
305
|
-
if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
|
|
306
|
-
setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
|
|
307
|
-
}
|
|
308
|
+
clearKeyframeCacheForElement(sourceFile, elementId);
|
|
308
309
|
return;
|
|
309
310
|
}
|
|
310
311
|
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
|
@@ -358,14 +359,11 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
358
359
|
const sf = sourceFile;
|
|
359
360
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
360
361
|
if (!parsed) return;
|
|
361
|
-
const { setKeyframeCache
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
setKeyframeCache(key, undefined);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
362
|
+
const { setKeyframeCache } = usePlayerStore.getState();
|
|
363
|
+
// Drop the file's stale entries (including the bare keys consumers read)
|
|
364
|
+
// before repopulating, so an element whose keyframes were removed and is
|
|
365
|
+
// absent from this scan doesn't keep showing diamonds.
|
|
366
|
+
clearKeyframeCacheForFile(sf);
|
|
369
367
|
const { elements } = usePlayerStore.getState();
|
|
370
368
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
371
369
|
for (const anim of parsed.animations) {
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
|
-
|
|
5
|
-
type CommitMutationOptions = {
|
|
6
|
-
label: string;
|
|
7
|
-
coalesceKey?: string;
|
|
8
|
-
softReload?: boolean;
|
|
9
|
-
skipReload?: boolean;
|
|
10
|
-
beforeReload?: () => void;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
type CommitMutation = (
|
|
14
|
-
selection: DomEditSelection,
|
|
15
|
-
mutation: Record<string, unknown>,
|
|
16
|
-
options: CommitMutationOptions,
|
|
17
|
-
) => Promise<void>;
|
|
4
|
+
import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
18
5
|
|
|
19
6
|
type TrackGsapSaveFailure = (
|
|
20
7
|
error: unknown,
|
|
@@ -4,8 +4,12 @@ import {
|
|
|
4
4
|
runShadowDelete,
|
|
5
5
|
runShadowTiming,
|
|
6
6
|
runShadowGsapTween,
|
|
7
|
+
runShadowGsapFidelity,
|
|
8
|
+
gsapFidelityMismatches,
|
|
9
|
+
resolveGsapFidelityArgs,
|
|
7
10
|
SdkShadowMismatch,
|
|
8
11
|
} from "./sdkShadow";
|
|
12
|
+
import type { ShadowGsapOp } from "./sdkShadow";
|
|
9
13
|
import type { PatchOperation } from "./sourcePatcher";
|
|
10
14
|
import { openComposition } from "@hyperframes/sdk";
|
|
11
15
|
|
|
@@ -219,13 +223,24 @@ describe("runShadowTiming", () => {
|
|
|
219
223
|
});
|
|
220
224
|
|
|
221
225
|
describe("runShadowGsapTween", () => {
|
|
222
|
-
it("
|
|
226
|
+
it("add reports success and the new tween lands on the target's animationIds", async () => {
|
|
223
227
|
const session = await openComposition(GSAP_HTML);
|
|
228
|
+
const before = session.getElement("hf-box")?.animationIds.length ?? 0;
|
|
224
229
|
runShadowGsapTween(session, {
|
|
225
230
|
kind: "add",
|
|
226
231
|
target: "hf-box",
|
|
227
232
|
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
228
233
|
});
|
|
234
|
+
expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
|
|
235
|
+
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("remove drops the tween from animationIds and reports parity", async () => {
|
|
239
|
+
const session = await openComposition(GSAP_HTML);
|
|
240
|
+
const animationId = session.getElement("hf-box")?.animationIds[0];
|
|
241
|
+
expect(animationId).toBeDefined();
|
|
242
|
+
runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
|
|
243
|
+
expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
|
|
229
244
|
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
230
245
|
});
|
|
231
246
|
|
|
@@ -244,3 +259,118 @@ describe("runShadowGsapTween", () => {
|
|
|
244
259
|
});
|
|
245
260
|
});
|
|
246
261
|
});
|
|
262
|
+
|
|
263
|
+
const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
|
|
264
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
265
|
+
window.__timelines["t"] = tl;`;
|
|
266
|
+
|
|
267
|
+
describe("gsapFidelityMismatches", () => {
|
|
268
|
+
it("returns no mismatches for identical scripts", () => {
|
|
269
|
+
expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("flags a per-field value drift (duration)", () => {
|
|
273
|
+
const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
|
|
274
|
+
const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
|
|
275
|
+
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("flags a tween present in one script but not the other", () => {
|
|
279
|
+
const empty = `var tl = gsap.timeline({ paused: true });
|
|
280
|
+
window.__timelines["t"] = tl;`;
|
|
281
|
+
const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
|
|
282
|
+
expect(mismatches.some((m) => m.property === "tween")).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does NOT flag property key-order differences (canonical compare)", () => {
|
|
286
|
+
const ab = `var tl = gsap.timeline({ paused: true });
|
|
287
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
|
|
288
|
+
window.__timelines["t"] = tl;`;
|
|
289
|
+
const ba = `var tl = gsap.timeline({ paused: true });
|
|
290
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
|
|
291
|
+
window.__timelines["t"] = tl;`;
|
|
292
|
+
expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does NOT flag number-vs-string-equivalent property values", () => {
|
|
296
|
+
const numeric = `var tl = gsap.timeline({ paused: true });
|
|
297
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
|
298
|
+
window.__timelines["t"] = tl;`;
|
|
299
|
+
const stringy = `var tl = gsap.timeline({ paused: true });
|
|
300
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
|
|
301
|
+
window.__timelines["t"] = tl;`;
|
|
302
|
+
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("runShadowGsapFidelity", () => {
|
|
307
|
+
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
|
308
|
+
<div data-hf-id="hf-box" style="opacity:0"></div>
|
|
309
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
310
|
+
window.__timelines["t"] = tl;</script>
|
|
311
|
+
</div>`;
|
|
312
|
+
|
|
313
|
+
it("reports zero mismatches when the SDK output matches the server script", async () => {
|
|
314
|
+
// Produce the "server" script by applying the same op via the SDK, so a
|
|
315
|
+
// faithful SDK writer must reproduce it exactly.
|
|
316
|
+
const ref = await openComposition(BEFORE_HTML);
|
|
317
|
+
const op = {
|
|
318
|
+
kind: "add",
|
|
319
|
+
target: "hf-box",
|
|
320
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
321
|
+
} as const;
|
|
322
|
+
ref.addGsapTween(op.target, op.tween);
|
|
323
|
+
const serverScript =
|
|
324
|
+
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
|
|
325
|
+
|
|
326
|
+
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
|
327
|
+
expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("reports mismatches when the server script diverges", async () => {
|
|
331
|
+
const op = {
|
|
332
|
+
kind: "add",
|
|
333
|
+
target: "hf-box",
|
|
334
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
335
|
+
} as const;
|
|
336
|
+
const ref = await openComposition(BEFORE_HTML);
|
|
337
|
+
ref.addGsapTween(op.target, op.tween);
|
|
338
|
+
const serverScript = (
|
|
339
|
+
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
|
|
340
|
+
).replace("100", "999");
|
|
341
|
+
|
|
342
|
+
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
|
343
|
+
const ev = lastShadow();
|
|
344
|
+
expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
|
|
345
|
+
expect(ev?.mismatchCount as number).toBeGreaterThan(0);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
|
|
350
|
+
const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
|
|
351
|
+
const session = {} as object;
|
|
352
|
+
|
|
353
|
+
it("returns narrowed args when session, op, before, and serverScript are all present", () => {
|
|
354
|
+
expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
|
|
355
|
+
before: "<html>before</html>",
|
|
356
|
+
op,
|
|
357
|
+
serverScript: "tl.to(...)",
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns null when no session (shadow not wired)", () => {
|
|
362
|
+
expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
|
|
366
|
+
expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns null when serverScript is null (composition has no GSAP script)", () => {
|
|
370
|
+
expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("returns null when before is null", () => {
|
|
374
|
+
expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
});
|