@hyperframes/studio 0.6.106 → 0.6.107
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-koqvg-_0.js → index-CVHV-S-B.js} +141 -141
- package/dist/assets/{index-CRCE5Dz_.js → index-P52mbTRb.js} +1 -1
- package/dist/assets/{index-CBS_7p5W.js → index-zIK7PWvk.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/hooks/gsapScriptCommitTypes.ts +11 -0
- package/src/hooks/serializeByKey.test.ts +83 -0
- package/src/hooks/serializeByKey.ts +33 -0
- package/src/hooks/useGsapAnimationOps.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.ts +21 -1
- package/src/hooks/useGsapScriptCommits.ts +33 -5
- package/src/utils/sdkShadow.test.ts +187 -1
- package/src/utils/sdkShadow.ts +67 -10
- package/src/utils/sdkShadowGsapFidelity.ts +46 -18
- package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
- package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
- package/src/utils/sdkShadowNumeric.ts +11 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
import{g as P}from"./index-
|
|
1
|
+
import{g as P}from"./index-CVHV-S-B.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
|
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-CVHV-S-B.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-BrhJl2JY.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.107",
|
|
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/
|
|
38
|
-
"@hyperframes/
|
|
36
|
+
"@hyperframes/core": "0.6.107",
|
|
37
|
+
"@hyperframes/player": "0.6.107",
|
|
38
|
+
"@hyperframes/sdk": "0.6.107"
|
|
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.107"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "19",
|
|
@@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
5
|
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
|
6
|
+
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
6
7
|
|
|
7
8
|
export interface MutationResult {
|
|
8
9
|
ok: boolean;
|
|
@@ -19,8 +20,18 @@ export interface CommitMutationOptions {
|
|
|
19
20
|
softReload?: boolean;
|
|
20
21
|
skipReload?: boolean;
|
|
21
22
|
beforeReload?: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Serialize this commit against others sharing the same key. Used to chain
|
|
25
|
+
* per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
|
|
26
|
+
* one file can't interleave — which would pair the shadow fidelity diff with a
|
|
27
|
+
* stale server result and report false ease mismatches. Commits without a key
|
|
28
|
+
* (and under distinct keys) run concurrently as before.
|
|
29
|
+
*/
|
|
30
|
+
serializeKey?: string;
|
|
22
31
|
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
|
23
32
|
shadowGsapOp?: ShadowGsapOp;
|
|
33
|
+
/** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
|
|
34
|
+
shadowKeyframeOp?: ShadowKeyframeOp;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export type CommitMutation = (
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createKeyedSerializer } from "./serializeByKey";
|
|
3
|
+
|
|
4
|
+
function deferred<T>() {
|
|
5
|
+
let resolve!: (value: T) => void;
|
|
6
|
+
let reject!: (reason?: unknown) => void;
|
|
7
|
+
const promise = new Promise<T>((res, rej) => {
|
|
8
|
+
resolve = res;
|
|
9
|
+
reject = rej;
|
|
10
|
+
});
|
|
11
|
+
return { promise, resolve, reject };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("createKeyedSerializer", () => {
|
|
15
|
+
it("runs same-key tasks strictly in order (second awaits the first)", async () => {
|
|
16
|
+
const run = createKeyedSerializer();
|
|
17
|
+
const order: string[] = [];
|
|
18
|
+
const first = deferred<void>();
|
|
19
|
+
|
|
20
|
+
const p1 = run("k", async () => {
|
|
21
|
+
order.push("1-start");
|
|
22
|
+
await first.promise;
|
|
23
|
+
order.push("1-end");
|
|
24
|
+
});
|
|
25
|
+
const p2 = run("k", async () => {
|
|
26
|
+
order.push("2-start");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Second task must not start until the first finishes.
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
expect(order).toEqual(["1-start"]);
|
|
32
|
+
|
|
33
|
+
first.resolve();
|
|
34
|
+
await Promise.all([p1, p2]);
|
|
35
|
+
expect(order).toEqual(["1-start", "1-end", "2-start"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not block tasks under different keys", async () => {
|
|
39
|
+
const run = createKeyedSerializer();
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
const blockA = deferred<void>();
|
|
42
|
+
|
|
43
|
+
const pa = run("a", async () => {
|
|
44
|
+
order.push("a-start");
|
|
45
|
+
await blockA.promise;
|
|
46
|
+
order.push("a-end");
|
|
47
|
+
});
|
|
48
|
+
const pb = run("b", async () => {
|
|
49
|
+
order.push("b-start");
|
|
50
|
+
order.push("b-end");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Different key runs to completion while "a" is still blocked.
|
|
54
|
+
await pb;
|
|
55
|
+
expect(order).toEqual(["a-start", "b-start", "b-end"]);
|
|
56
|
+
|
|
57
|
+
blockA.resolve();
|
|
58
|
+
await pa;
|
|
59
|
+
expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not wedge a key when a prior task rejects", async () => {
|
|
63
|
+
const run = createKeyedSerializer();
|
|
64
|
+
const order: string[] = [];
|
|
65
|
+
|
|
66
|
+
const p1 = run("k", async () => {
|
|
67
|
+
order.push("1");
|
|
68
|
+
throw new Error("boom");
|
|
69
|
+
});
|
|
70
|
+
await expect(p1).rejects.toThrow("boom");
|
|
71
|
+
|
|
72
|
+
const p2 = run("k", async () => {
|
|
73
|
+
order.push("2");
|
|
74
|
+
});
|
|
75
|
+
await p2;
|
|
76
|
+
expect(order).toEqual(["1", "2"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("propagates the task's resolved value to its caller", async () => {
|
|
80
|
+
const run = createKeyedSerializer();
|
|
81
|
+
await expect(run("k", async () => 42)).resolves.toBe(42);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key task serializer. Tasks sharing a key run strictly in order: a new
|
|
3
|
+
* task for a key awaits the prior task for that key before starting, so their
|
|
4
|
+
* effects (e.g. overlapping read-modify-write POSTs to one file) can't
|
|
5
|
+
* interleave. Tasks under different keys are independent and never block each
|
|
6
|
+
* other.
|
|
7
|
+
*
|
|
8
|
+
* Used to serialize GSAP meta-update commits per animationId so the shadow
|
|
9
|
+
* fidelity diff always pairs an op with the server result that includes it —
|
|
10
|
+
* without globally serializing unrelated commits.
|
|
11
|
+
*/
|
|
12
|
+
export function createKeyedSerializer() {
|
|
13
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
14
|
+
|
|
15
|
+
return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
16
|
+
const prior = inFlight.get(key) ?? Promise.resolve();
|
|
17
|
+
// Chain onto the prior task regardless of how it settled; a rejected prior
|
|
18
|
+
// commit must not wedge the key forever.
|
|
19
|
+
const next = prior.then(task, task);
|
|
20
|
+
inFlight.set(key, next);
|
|
21
|
+
// Once this task settles, drop it from the map if nothing newer replaced it,
|
|
22
|
+
// so completed keys don't leak.
|
|
23
|
+
void next.then(
|
|
24
|
+
() => {
|
|
25
|
+
if (inFlight.get(key) === next) inFlight.delete(key);
|
|
26
|
+
},
|
|
27
|
+
() => {
|
|
28
|
+
if (inFlight.get(key) === next) inFlight.delete(key);
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
return next;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
|
|
|
40
40
|
animationId,
|
|
41
41
|
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
|
42
42
|
};
|
|
43
|
+
// coalesceKey groups rapid meta edits into one history entry. Request
|
|
44
|
+
// serialization is now handled per-file at the commitMutation chokepoint
|
|
45
|
+
// (useGsapScriptCommits), so no per-op serializeKey is needed here.
|
|
46
|
+
const metaKey = `gsap:${animationId}:meta`;
|
|
43
47
|
commitMutationSafely(
|
|
44
48
|
selection,
|
|
45
49
|
{ type: "update-meta", animationId, updates },
|
|
46
|
-
{ label: "Edit GSAP animation", coalesceKey:
|
|
50
|
+
{ label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
|
|
47
51
|
);
|
|
48
52
|
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
49
53
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
5
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
|
5
6
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
|
|
|
58
59
|
percentage,
|
|
59
60
|
properties: { [property]: value },
|
|
60
61
|
};
|
|
62
|
+
// Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
|
|
63
|
+
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
64
|
+
kind: "add",
|
|
65
|
+
animationId,
|
|
66
|
+
percentage,
|
|
67
|
+
properties: { [property]: value },
|
|
68
|
+
};
|
|
61
69
|
void executeOptimisticKeyframeCacheUpdate({
|
|
62
70
|
sourceFile,
|
|
63
71
|
elementId: selection.id,
|
|
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
|
|
|
71
79
|
commitMutation(selection, mutation, {
|
|
72
80
|
label: `Add keyframe at ${percentage}%`,
|
|
73
81
|
softReload: true,
|
|
82
|
+
shadowKeyframeOp,
|
|
74
83
|
}),
|
|
75
84
|
}).catch((error) => {
|
|
76
85
|
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
|
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
|
|
|
86
95
|
percentage: number,
|
|
87
96
|
properties: Record<string, number | string>,
|
|
88
97
|
) => {
|
|
98
|
+
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
99
|
+
kind: "add",
|
|
100
|
+
animationId,
|
|
101
|
+
percentage,
|
|
102
|
+
properties,
|
|
103
|
+
};
|
|
89
104
|
return commitMutation(
|
|
90
105
|
selection,
|
|
91
106
|
{ type: "add-keyframe", animationId, percentage, properties },
|
|
92
|
-
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
|
107
|
+
{ label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
|
|
93
108
|
);
|
|
94
109
|
},
|
|
95
110
|
[commitMutation],
|
|
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
|
|
|
99
114
|
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
|
100
115
|
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
|
101
116
|
const mutation = { type: "remove-keyframe", animationId, percentage };
|
|
117
|
+
// Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
|
|
118
|
+
// so the runner resolves percentage → keyframeIndex against the pre-op
|
|
119
|
+
// script and no-ops on ambiguity (duplicate-percentage keyframes).
|
|
120
|
+
const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
|
|
102
121
|
void executeOptimisticKeyframeCacheUpdate({
|
|
103
122
|
sourceFile,
|
|
104
123
|
elementId: selection.id,
|
|
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
|
|
|
112
131
|
commitMutation(selection, mutation, {
|
|
113
132
|
label: `Remove keyframe at ${percentage}%`,
|
|
114
133
|
softReload: true,
|
|
134
|
+
shadowKeyframeOp,
|
|
115
135
|
}),
|
|
116
136
|
}).catch((error) => {
|
|
117
137
|
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } 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
5
|
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
|
|
6
|
+
import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
|
|
6
7
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
8
|
+
import { createKeyedSerializer } from "./serializeByKey";
|
|
7
9
|
import {
|
|
8
10
|
GsapMutationHttpError,
|
|
9
11
|
formatGsapMutationRejectionToast,
|
|
@@ -45,10 +47,16 @@ async function mutateGsapScript(
|
|
|
45
47
|
// oxfmt-ignore
|
|
46
48
|
// fallow-ignore-next-line complexity
|
|
47
49
|
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
|
50
|
+
// Serializer for per-key commits (options.serializeKey). Keyed by
|
|
51
|
+
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
|
52
|
+
// the same animationId so their POSTs can't interleave — which is what made
|
|
53
|
+
// the shadow fidelity diff pair an op with a stale server result and report
|
|
54
|
+
// false ease mismatches. Held in a ref so the chain survives re-renders.
|
|
55
|
+
const serializerRef = useRef(createKeyedSerializer());
|
|
48
56
|
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
|
49
57
|
// adds only a guarded shadow-fidelity dispatch.
|
|
50
58
|
// fallow-ignore-next-line complexity
|
|
51
|
-
const
|
|
59
|
+
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
52
60
|
const pid = projectIdRef.current;
|
|
53
61
|
if (!pid) return;
|
|
54
62
|
const unsafeFields = findUnsafeMutationValues(mutation);
|
|
@@ -70,9 +78,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
70
78
|
domEditSaveTimestampRef.current = Date.now();
|
|
71
79
|
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
72
80
|
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
73
|
-
//
|
|
74
|
-
// useGsapAnimationOps)
|
|
75
|
-
// useGsapKeyframeOps
|
|
81
|
+
// Meta-level ops carry shadowGsapOp (add / update-meta / delete via
|
|
82
|
+
// useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
|
|
83
|
+
// useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
|
|
84
|
+
// handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
|
|
76
85
|
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
77
86
|
const fidelityArgs = resolveGsapFidelityArgs(
|
|
78
87
|
sdkSession,
|
|
@@ -83,6 +92,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
83
92
|
if (fidelityArgs) {
|
|
84
93
|
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
85
94
|
}
|
|
95
|
+
// Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
|
|
96
|
+
// the SDK has no keyframe reader so there is no live-existence path — the diff
|
|
97
|
+
// is the only signal. Guarded on a live session + both scripts to diff.
|
|
98
|
+
if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
|
|
99
|
+
void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
|
|
100
|
+
}
|
|
86
101
|
if (result.before != null && result.after != null) {
|
|
87
102
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
88
103
|
}
|
|
@@ -97,6 +112,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
97
112
|
}
|
|
98
113
|
onCacheInvalidate();
|
|
99
114
|
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
|
|
115
|
+
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
116
|
+
// commits to the SAME file (any op type, any animation) interleave server-side
|
|
117
|
+
// and make the shadow fidelity diff pair an op with a stale server result —
|
|
118
|
+
// the false ease/value mismatches this serializer exists to prevent. So
|
|
119
|
+
// serialize per target file by default; an explicit serializeKey overrides.
|
|
120
|
+
const commitMutation = useCallback(
|
|
121
|
+
(selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
122
|
+
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
123
|
+
const key = options.serializeKey ?? `gsap-file:${file}`;
|
|
124
|
+
return serializerRef.current(key, () => runCommit(selection, mutation, options));
|
|
125
|
+
},
|
|
126
|
+
[runCommit, activeCompPath],
|
|
127
|
+
);
|
|
100
128
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
101
129
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
102
130
|
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
patchOpsToSdkEditOps,
|
|
4
4
|
runShadowDelete,
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
SdkShadowMismatch,
|
|
11
11
|
} from "./sdkShadow";
|
|
12
12
|
import type { ShadowGsapOp } from "./sdkShadow";
|
|
13
|
+
import { makeSelectorResolver } from "./sdkShadowGsapFidelity";
|
|
13
14
|
import type { PatchOperation } from "./sourcePatcher";
|
|
14
15
|
import { openComposition } from "@hyperframes/sdk";
|
|
16
|
+
import { Window } from "happy-dom";
|
|
15
17
|
|
|
16
18
|
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
|
|
17
19
|
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
@@ -108,6 +110,7 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
108
110
|
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
|
|
109
111
|
});
|
|
110
112
|
|
|
113
|
+
// fallow-ignore-next-line code-duplication
|
|
111
114
|
it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
|
|
112
115
|
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
113
116
|
const session = await openComposition(BASE_HTML);
|
|
@@ -147,6 +150,59 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
147
150
|
expect(session.getElement("hf-box")?.text).toBe("Updated");
|
|
148
151
|
});
|
|
149
152
|
|
|
153
|
+
// Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a
|
|
154
|
+
// trailing-whitespace-only difference between the op value and the snapshot must
|
|
155
|
+
// not flag.
|
|
156
|
+
it("does NOT false-mismatch trailing-whitespace-only text difference", async () => {
|
|
157
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
158
|
+
const session = await openComposition(BASE_HTML);
|
|
159
|
+
|
|
160
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }];
|
|
161
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
162
|
+
|
|
163
|
+
expect(result.dispatched).toBe(true);
|
|
164
|
+
expect(result.mismatches).toHaveLength(0); // trimmed both sides
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Empty-string op value vs an absent (null) snapshot text must collapse to equal
|
|
168
|
+
// — both mean "no text content".
|
|
169
|
+
it("treats empty-string text op and null snapshot text as equal", async () => {
|
|
170
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
171
|
+
const EMPTY_HTML = /* html */ `<!DOCTYPE html>
|
|
172
|
+
<html><body><img data-hf-id="hf-img" src="x.png" /></body></html>`;
|
|
173
|
+
const session = await openComposition(EMPTY_HTML);
|
|
174
|
+
|
|
175
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }];
|
|
176
|
+
const result = sdkShadowDispatch(session, "hf-img", ops);
|
|
177
|
+
|
|
178
|
+
expect(result.dispatched).toBe(true);
|
|
179
|
+
expect(result.mismatches).toHaveLength(0); // "" vs null → both null
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style
|
|
183
|
+
// read-back already reads only the AUTHORED style attribute (getElementStyles →
|
|
184
|
+
// parseStyleAttr), never computed styles. The transform-origin divergence
|
|
185
|
+
// (expected null actual "center center") was a genuine SDK bug — setStyle
|
|
186
|
+
// removal of a HYPHENATED property silently no-opped because setElementStyles
|
|
187
|
+
// deleted the kebab key while the style map is keyed camelCase. Now FIXED in
|
|
188
|
+
// the SDK (model.ts setElementStyles normalizes the key via toCamel), so the
|
|
189
|
+
// shadow sees parity: removal applies and there is no mismatch.
|
|
190
|
+
it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => {
|
|
191
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
192
|
+
const TO_HTML = /* html */ `<!DOCTYPE html>
|
|
193
|
+
<html><body><div data-hf-id="hf-box" style="transform-origin: center center">x</div></body></html>`;
|
|
194
|
+
const session = await openComposition(TO_HTML);
|
|
195
|
+
|
|
196
|
+
const ops: PatchOperation[] = [
|
|
197
|
+
{ type: "inline-style", property: "transform-origin", value: null },
|
|
198
|
+
];
|
|
199
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
200
|
+
|
|
201
|
+
// The SDK now removes the hyphenated property, so the shadow read-back agrees.
|
|
202
|
+
expect(result.dispatched).toBe(true);
|
|
203
|
+
expect(result.mismatches).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
150
206
|
it("applies attribute op and reads back via session.getElement", async () => {
|
|
151
207
|
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
152
208
|
const session = await openComposition(BASE_HTML);
|
|
@@ -237,6 +293,35 @@ describe("runShadowDelete", () => {
|
|
|
237
293
|
reason: "cannot_dispatch",
|
|
238
294
|
});
|
|
239
295
|
});
|
|
296
|
+
|
|
297
|
+
// Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a
|
|
298
|
+
// bare hf-id collides between a sub-composition element (scopedId
|
|
299
|
+
// "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement
|
|
300
|
+
// resolves the bare id via resolveScoped → querySelector (document-order-first,
|
|
301
|
+
// removes the INNER instance), but getElement prefers the canonical top-level
|
|
302
|
+
// match (scopedId === id) which SURVIVES. The shadow then correctly reports
|
|
303
|
+
// expected "removed" / actual "present". The readback here is correct (it checks
|
|
304
|
+
// the same id it dispatched); the fix belongs in the SDK's id resolution
|
|
305
|
+
// (resolveScoped vs getElement agreement), not in this file.
|
|
306
|
+
const DUP_ID_HTML = /* html */ `<!DOCTYPE html><html><body>
|
|
307
|
+
<div data-hf-id="hf-root" data-hf-root>
|
|
308
|
+
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
|
309
|
+
<div data-hf-id="hf-dup">inner</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div data-hf-id="hf-dup">outer</div>
|
|
312
|
+
</div>
|
|
313
|
+
</body></html>`;
|
|
314
|
+
|
|
315
|
+
it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => {
|
|
316
|
+
const session = await openComposition(DUP_ID_HTML);
|
|
317
|
+
runShadowDelete(session, "hf-dup");
|
|
318
|
+
// SDK fix (agree removeElement/getElement on duplicate bare ids): both now
|
|
319
|
+
// resolve a bare id to the canonical (top-level) instance, so removeElement
|
|
320
|
+
// drops exactly the element the readback checks → no mismatch. (Previously
|
|
321
|
+
// removeElement dropped the inner instance while the top-level survived,
|
|
322
|
+
// which this shadow correctly flagged; that divergence is now fixed.)
|
|
323
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
|
324
|
+
});
|
|
240
325
|
});
|
|
241
326
|
|
|
242
327
|
describe("runShadowTiming", () => {
|
|
@@ -249,6 +334,38 @@ describe("runShadowTiming", () => {
|
|
|
249
334
|
expect(el?.trackIndex).toBe(1);
|
|
250
335
|
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
251
336
|
});
|
|
337
|
+
|
|
338
|
+
// Fix 1: float-precision tolerance. The SDK computes durations arithmetically
|
|
339
|
+
// (returning e.g. 3.0999999999999996); the server stores the rounded literal
|
|
340
|
+
// (3.1). A relative epsilon must treat these as equal, while a real difference
|
|
341
|
+
// still flags. A fake session returns the imprecise value on read-back.
|
|
342
|
+
type FakeTiming = { start?: number; duration?: number; trackIndex?: number };
|
|
343
|
+
function fakeTimingSession(readback: FakeTiming) {
|
|
344
|
+
return {
|
|
345
|
+
can: () => ({ ok: true }),
|
|
346
|
+
batch: (fn: () => void) => fn(),
|
|
347
|
+
dispatch: () => {},
|
|
348
|
+
getElement: () => readback,
|
|
349
|
+
} as unknown as Parameters<typeof runShadowTiming>[0];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => {
|
|
353
|
+
const session = fakeTimingSession({ duration: 3.0999999999999996 });
|
|
354
|
+
runShadowTiming(session, "hf-clip", { duration: 3.1 });
|
|
355
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => {
|
|
359
|
+
const session = fakeTimingSession({ start: 21.360000000000014 });
|
|
360
|
+
runShadowTiming(session, "hf-clip", { start: 21.36 });
|
|
361
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("STILL flags a real duration difference (3.1 vs 3.5)", () => {
|
|
365
|
+
const session = fakeTimingSession({ duration: 3.5 });
|
|
366
|
+
runShadowTiming(session, "hf-clip", { duration: 3.1 });
|
|
367
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 });
|
|
368
|
+
});
|
|
252
369
|
});
|
|
253
370
|
|
|
254
371
|
describe("runShadowGsapTween", () => {
|
|
@@ -304,6 +421,29 @@ describe("gsapFidelityMismatches", () => {
|
|
|
304
421
|
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
|
|
305
422
|
});
|
|
306
423
|
|
|
424
|
+
it("does NOT flag sub-ULP float-formatting noise in duration", () => {
|
|
425
|
+
// 3.1 vs 3.0999999999999996 is the same value after writer round-trips;
|
|
426
|
+
// relative-epsilon compare must treat it as equal, not drift.
|
|
427
|
+
const sdk = `var tl = gsap.timeline({ paused: true });
|
|
428
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0);
|
|
429
|
+
window.__timelines["t"] = tl;`;
|
|
430
|
+
const server = `var tl = gsap.timeline({ paused: true });
|
|
431
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0);
|
|
432
|
+
window.__timelines["t"] = tl;`;
|
|
433
|
+
expect(gsapFidelityMismatches(sdk, server)).toEqual([]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => {
|
|
437
|
+
const sdk = `var tl = gsap.timeline({ paused: true });
|
|
438
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
|
|
439
|
+
window.__timelines["t"] = tl;`;
|
|
440
|
+
const server = `var tl = gsap.timeline({ paused: true });
|
|
441
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0);
|
|
442
|
+
window.__timelines["t"] = tl;`;
|
|
443
|
+
const mismatches = gsapFidelityMismatches(sdk, server);
|
|
444
|
+
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
307
447
|
it("flags a tween present in one script but not the other", () => {
|
|
308
448
|
const empty = `var tl = gsap.timeline({ paused: true });
|
|
309
449
|
window.__timelines["t"] = tl;`;
|
|
@@ -345,6 +485,52 @@ window.__timelines["t"] = tl;`;
|
|
|
345
485
|
// With a resolver: matched by element → no mismatch.
|
|
346
486
|
expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
|
|
347
487
|
});
|
|
488
|
+
|
|
489
|
+
// Drive makeSelectorResolver against a real DOM (happy-dom shims the
|
|
490
|
+
// browser-only DOMParser the resolver depends on; the studio test env is node).
|
|
491
|
+
describe("makeSelectorResolver unifies selector forms (real DOM)", () => {
|
|
492
|
+
const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser;
|
|
493
|
+
beforeEach(() => {
|
|
494
|
+
(globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser;
|
|
495
|
+
});
|
|
496
|
+
afterEach(() => {
|
|
497
|
+
(globalThis as { DOMParser?: unknown }).DOMParser = origDomParser;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => {
|
|
501
|
+
// Element carries all three forms; the server may emit #id or .class while
|
|
502
|
+
// the SDK emits [data-hf-id]. All must resolve to the same canonical key.
|
|
503
|
+
const html = `<div data-hf-id="hf-9flp" class="caption-layer" id="intro-layer"></div>`;
|
|
504
|
+
const resolve = makeSelectorResolver(html);
|
|
505
|
+
const viaHfId = resolve('[data-hf-id="hf-9flp"]');
|
|
506
|
+
expect(resolve(".caption-layer")).toBe(viaHfId);
|
|
507
|
+
expect(resolve("#intro-layer")).toBe(viaHfId);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => {
|
|
511
|
+
const html = `<div data-hf-id="hf-9flp" class="caption-layer"></div>`;
|
|
512
|
+
const resolve = makeSelectorResolver(html);
|
|
513
|
+
const sdkScript = `var tl = gsap.timeline({ paused: true });
|
|
514
|
+
tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0);
|
|
515
|
+
window.__timelines["t"] = tl;`;
|
|
516
|
+
const serverScript = `var tl = gsap.timeline({ paused: true });
|
|
517
|
+
tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0);
|
|
518
|
+
window.__timelines["t"] = tl;`;
|
|
519
|
+
// Without unification these flag present/absent; the resolver collapses them.
|
|
520
|
+
expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0);
|
|
521
|
+
expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("collapses different selector forms for an element WITHOUT a data-hf-id", () => {
|
|
525
|
+
// No hf-id present: the resolver must still key both forms to the same node
|
|
526
|
+
// (not leave .class vs #id as distinct raw-selector keys).
|
|
527
|
+
const html = `<div class="caption-layer" id="intro-layer"></div>`;
|
|
528
|
+
const resolve = makeSelectorResolver(html);
|
|
529
|
+
expect(resolve(".caption-layer")).toBe(resolve("#intro-layer"));
|
|
530
|
+
// And it is NOT the raw selector fallback.
|
|
531
|
+
expect(resolve(".caption-layer")).not.toBe(".caption-layer");
|
|
532
|
+
});
|
|
533
|
+
});
|
|
348
534
|
});
|
|
349
535
|
|
|
350
536
|
describe("runShadowGsapFidelity", () => {
|