@hyperframes/studio 0.6.105 → 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-BITwbxi-.css → index-BrhJl2JY.css} +1 -1
- package/dist/assets/index-CVHV-S-B.js +296 -0
- package/dist/assets/{index-NIdeStFw.js → index-P52mbTRb.js} +1 -1
- package/dist/assets/{index-By4Ku5HI.js → index-zIK7PWvk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +8 -24
- package/src/components/editor/ComputedTweenNotice.tsx +40 -0
- package/src/components/editor/GsapAnimationSection.tsx +5 -24
- package/src/components/editor/PropertyPanel.tsx +2 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +33 -0
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +90 -3
- package/src/hooks/gsapRuntimeBridge.ts +70 -19
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +198 -123
- 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/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommits.ts +9 -1
- package/src/hooks/useGsapAnimationOps.ts +5 -1
- package/src/hooks/useGsapAwareEditing.ts +21 -0
- package/src/hooks/useGsapKeyframeOps.ts +21 -1
- package/src/hooks/useGsapScriptCommits.ts +33 -5
- package/src/hooks/useGsapTweenCache.ts +49 -7
- 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
- package/dist/assets/index-CKd39gmy.js +0 -296
|
@@ -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);
|
|
@@ -211,6 +211,7 @@ export function useGsapAnimationsForElement(
|
|
|
211
211
|
keyframes: runtime.keyframes,
|
|
212
212
|
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
|
|
213
213
|
},
|
|
214
|
+
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
|
|
214
215
|
};
|
|
215
216
|
});
|
|
216
217
|
}
|
|
@@ -243,6 +244,7 @@ export function useGsapAnimationsForElement(
|
|
|
243
244
|
keyframes: runtimeEntry.keyframes,
|
|
244
245
|
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
|
|
245
246
|
},
|
|
247
|
+
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
|
|
246
248
|
},
|
|
247
249
|
];
|
|
248
250
|
}
|
|
@@ -358,19 +360,30 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
358
360
|
|
|
359
361
|
const sf = sourceFile;
|
|
360
362
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
361
|
-
if (!parsed)
|
|
363
|
+
if (!parsed) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
362
366
|
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
367
|
clearKeyframeCacheForFile(sf);
|
|
367
368
|
const { elements } = usePlayerStore.getState();
|
|
369
|
+
console.log(
|
|
370
|
+
"[kf:static] elements in store:",
|
|
371
|
+
elements
|
|
372
|
+
.map((e) => e.domId)
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.join(", "),
|
|
375
|
+
);
|
|
368
376
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
369
377
|
for (const anim of parsed.animations) {
|
|
370
378
|
const id = extractIdFromSelector(anim.targetSelector);
|
|
371
379
|
if (!id) continue;
|
|
380
|
+
if (anim.hasUnresolvedKeyframes) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
372
383
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
373
|
-
if (!kfData)
|
|
384
|
+
if (!kfData) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
374
387
|
const tweenPos =
|
|
375
388
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
376
389
|
const tweenDur = anim.duration ?? 1;
|
|
@@ -402,6 +415,12 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
402
415
|
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
403
416
|
}
|
|
404
417
|
}
|
|
418
|
+
console.log(
|
|
419
|
+
"[kf:static] merged elements:",
|
|
420
|
+
[...mergedByElement.keys()].join(", "),
|
|
421
|
+
"kf counts:",
|
|
422
|
+
[...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
|
|
423
|
+
);
|
|
405
424
|
for (const [id, kfData] of mergedByElement) {
|
|
406
425
|
setKeyframeCache(`${sf}#${id}`, kfData);
|
|
407
426
|
setKeyframeCache(id, kfData);
|
|
@@ -428,14 +447,37 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
428
447
|
const iframe =
|
|
429
448
|
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
430
449
|
if (!iframe) return false;
|
|
431
|
-
|
|
450
|
+
// Clip dims per element so the scan converts tween-relative keyframes to
|
|
451
|
+
// clip-relative (matching the static path) instead of timeline-relative.
|
|
452
|
+
const clipById = new Map<string, { start: number; duration: number }>();
|
|
453
|
+
for (const el of usePlayerStore.getState().elements) {
|
|
454
|
+
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
|
|
455
|
+
}
|
|
456
|
+
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
457
|
+
console.log(
|
|
458
|
+
"[kf:runtime] scanned",
|
|
459
|
+
scanned.size,
|
|
460
|
+
"elements:",
|
|
461
|
+
[...scanned.keys()].join(", "),
|
|
462
|
+
);
|
|
432
463
|
if (scanned.size === 0) return false;
|
|
433
464
|
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
|
434
465
|
for (const [id, data] of scanned) {
|
|
435
466
|
const cacheKey = `${sf}#${id}`;
|
|
436
467
|
const fallbackKey = `index.html#${id}`;
|
|
437
|
-
|
|
468
|
+
const alreadyCached =
|
|
469
|
+
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
|
470
|
+
if (alreadyCached) {
|
|
438
471
|
continue;
|
|
472
|
+
}
|
|
473
|
+
console.log(
|
|
474
|
+
"[kf:runtime] adding runtime entry:",
|
|
475
|
+
id,
|
|
476
|
+
"kfs:",
|
|
477
|
+
data.keyframes.length,
|
|
478
|
+
"arc:",
|
|
479
|
+
!!data.arcPath,
|
|
480
|
+
);
|
|
439
481
|
const entry = {
|
|
440
482
|
format: "percentage" as const,
|
|
441
483
|
keyframes: data.keyframes,
|
|
@@ -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", () => {
|
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
12
12
|
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
|
13
13
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
14
14
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
15
|
+
import { relEqual } from "./sdkShadowNumeric";
|
|
15
16
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
16
17
|
import type { PatchOperation } from "./sourcePatcher";
|
|
17
18
|
|
|
@@ -39,6 +40,16 @@ function isShadowableOp(op: PatchOperation): boolean {
|
|
|
39
40
|
return true;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
|
|
44
|
+
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
|
|
45
|
+
// silently dropping it (see the unmapped_type guard there).
|
|
46
|
+
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
|
|
47
|
+
"inline-style",
|
|
48
|
+
"text-content",
|
|
49
|
+
"attribute",
|
|
50
|
+
"html-attribute",
|
|
51
|
+
]);
|
|
52
|
+
|
|
42
53
|
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
|
43
54
|
const result: EditOp[] = [];
|
|
44
55
|
const styles: Record<string, string | null> = {};
|
|
@@ -121,13 +132,29 @@ function kebabToCamel(prop: string): string {
|
|
|
121
132
|
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
|
|
136
|
+
// An empty string and absent text (null) are treated as equivalent (collapsed
|
|
137
|
+
// to null) so "" vs null does not flag — both mean "no text content".
|
|
138
|
+
function normalizeText(value: string | null | undefined): string | null {
|
|
139
|
+
if (value == null) return null;
|
|
140
|
+
const trimmed = value.trim();
|
|
141
|
+
return trimmed === "" ? null : trimmed;
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
|
|
125
145
|
"inline-style": (op, flat) => ({
|
|
126
146
|
property: op.property,
|
|
127
147
|
expected: op.value,
|
|
128
148
|
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
|
|
129
149
|
}),
|
|
130
|
-
|
|
150
|
+
// snapshot.text is already TRIMMED; trim the expected op value to match, so
|
|
151
|
+
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
|
|
152
|
+
// collapsed in checkOpParity. A genuinely different text value still flags.
|
|
153
|
+
"text-content": (op, flat) => ({
|
|
154
|
+
property: "text",
|
|
155
|
+
expected: normalizeText(op.value),
|
|
156
|
+
actual: normalizeText(flat.text),
|
|
157
|
+
}),
|
|
131
158
|
attribute: (op, flat) => ({
|
|
132
159
|
property: attrName(op.property),
|
|
133
160
|
expected: op.value ?? null,
|
|
@@ -244,6 +271,23 @@ export function runShadowDispatch(
|
|
|
244
271
|
});
|
|
245
272
|
return;
|
|
246
273
|
}
|
|
274
|
+
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
|
|
275
|
+
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
|
|
276
|
+
// unmapped_type event if a future type ever slips through, so the gap surfaces
|
|
277
|
+
// in telemetry instead of vanishing.
|
|
278
|
+
// Map to the type string before find, so a future unmapped type is read as a
|
|
279
|
+
// plain string (no object cast; find on the closed union narrows to never).
|
|
280
|
+
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
|
|
281
|
+
if (unmappedType !== undefined) {
|
|
282
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
283
|
+
op: "property",
|
|
284
|
+
dispatched: false,
|
|
285
|
+
reason: "unmapped_type",
|
|
286
|
+
type: unmappedType,
|
|
287
|
+
mismatchCount: 0,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
247
291
|
const result = sdkShadowDispatch(session, hfId, ops);
|
|
248
292
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
249
293
|
op: "property",
|
|
@@ -345,6 +389,20 @@ export interface ShadowTiming {
|
|
|
345
389
|
trackIndex?: number;
|
|
346
390
|
}
|
|
347
391
|
|
|
392
|
+
// start/duration tolerate float-precision drift (SDK computes them
|
|
393
|
+
// arithmetically, server stores a rounded literal) via the shared relative
|
|
394
|
+
// epsilon; trackIndex (integer track slot) is compared exactly.
|
|
395
|
+
function timingFieldEqual(
|
|
396
|
+
key: keyof ShadowTiming,
|
|
397
|
+
actual: number | null | undefined,
|
|
398
|
+
expected: number,
|
|
399
|
+
): boolean {
|
|
400
|
+
if (typeof actual === "number" && key !== "trackIndex") {
|
|
401
|
+
return relEqual(actual, expected);
|
|
402
|
+
}
|
|
403
|
+
return actual === expected;
|
|
404
|
+
}
|
|
405
|
+
|
|
348
406
|
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
|
349
407
|
export function runShadowTiming(
|
|
350
408
|
session: Composition,
|
|
@@ -373,15 +431,14 @@ export function runShadowTiming(
|
|
|
373
431
|
];
|
|
374
432
|
for (const [key, actual] of fields) {
|
|
375
433
|
const expected = timing[key];
|
|
376
|
-
if (expected
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
434
|
+
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
|
|
435
|
+
mismatches.push({
|
|
436
|
+
kind: "value_mismatch",
|
|
437
|
+
hfId,
|
|
438
|
+
property: key,
|
|
439
|
+
expected: String(expected),
|
|
440
|
+
actual: actual == null ? null : String(actual),
|
|
441
|
+
});
|
|
385
442
|
}
|
|
386
443
|
return mismatches;
|
|
387
444
|
});
|
|
@@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
|
16
16
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
17
17
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
18
18
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
19
|
+
import { relEqual } from "./sdkShadowNumeric";
|
|
19
20
|
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
|
20
21
|
|
|
21
22
|
// Marker set must match document.ts extractGsapScript so both pick the same
|
|
@@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean {
|
|
|
24
25
|
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function extractGsapScript(html: string): string | null {
|
|
28
|
+
export function extractGsapScript(html: string): string | null {
|
|
28
29
|
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
|
29
30
|
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
|
30
31
|
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
|
@@ -73,17 +74,17 @@ function animByKey(
|
|
|
73
74
|
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
|
74
75
|
// strings — so only real value drift registers, not formatting differences.
|
|
75
76
|
|
|
77
|
+
// Coerce string operands to numbers, then compare with the shared relative
|
|
78
|
+
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
|
|
79
|
+
// isn't flagged as drift while a real 2 vs 1 still is.
|
|
76
80
|
function numericEqual(a: unknown, b: unknown): boolean {
|
|
77
81
|
if (a === b) return true;
|
|
78
82
|
const na = typeof a === "string" ? Number(a) : a;
|
|
79
83
|
const nb = typeof b === "string" ? Number(b) : b;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
!Number.isNaN(nb) &&
|
|
85
|
-
na === nb
|
|
86
|
-
);
|
|
84
|
+
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return relEqual(na, nb);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
|
@@ -187,27 +188,54 @@ export function resolveGsapFidelityArgs(
|
|
|
187
188
|
return { before, op: shadowGsapOp, serverScript };
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
// Resolve a CSS selector to a canonical element
|
|
191
|
-
//
|
|
192
|
-
// ([data-hf-id="X"] vs .X)
|
|
193
|
-
//
|
|
191
|
+
// Resolve a CSS selector to a canonical element key using the pre-op document,
|
|
192
|
+
// so tweens that target the same element via different selectors
|
|
193
|
+
// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
|
|
194
|
+
//
|
|
195
|
+
// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
|
|
196
|
+
// selector for the SAME element. Keying both forms to the same node prevents a
|
|
197
|
+
// false present/absent mismatch. Resolution order, for whatever element the
|
|
198
|
+
// selector matches:
|
|
199
|
+
// 1. data-hf-id present → "hfid:<id>" (the common, stable case)
|
|
200
|
+
// 2. no data-hf-id → "node:<n>" (per-document node index; identical
|
|
201
|
+
// regardless of which selector form found the node, so .x and [data-hf-id]
|
|
202
|
+
// pointing at the same attribute-less node still collapse)
|
|
203
|
+
// 3. selector resolves to no node / parse error / no DOM → the raw selector
|
|
204
|
+
// (last resort; only diverges when the two writers genuinely target
|
|
205
|
+
// different — or unresolvable — nodes, which is real drift to surface)
|
|
206
|
+
// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
|
|
207
|
+
// collide with a raw-selector fallback.
|
|
194
208
|
//
|
|
195
209
|
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
|
|
196
|
-
// ambiguous selector (e.g. .x shared by two elements) may map to a different
|
|
197
|
-
// than the SDK side's [data-hf-id] target and still flag present/absent.
|
|
198
|
-
// for studio templates (one tween per
|
|
199
|
-
// uniqueness check if ambiguous selectors appear.
|
|
200
|
-
function makeSelectorResolver(html: string): (sel: string) => string {
|
|
210
|
+
// ambiguous selector (e.g. .x shared by two elements) may map to a different
|
|
211
|
+
// node than the SDK side's [data-hf-id] target and still flag present/absent.
|
|
212
|
+
// Safe for studio templates (one tween per element); upgrade to querySelectorAll
|
|
213
|
+
// + uniqueness check if ambiguous selectors appear.
|
|
214
|
+
export function makeSelectorResolver(html: string): (sel: string) => string {
|
|
201
215
|
let doc: Document | null = null;
|
|
202
216
|
try {
|
|
203
217
|
doc = new DOMParser().parseFromString(html, "text/html");
|
|
204
218
|
} catch {
|
|
205
219
|
doc = null;
|
|
206
220
|
}
|
|
221
|
+
// Stable per-node index so an attribute-less element keys identically no
|
|
222
|
+
// matter which selector form (class vs id vs [data-hf-id]) resolved it.
|
|
223
|
+
const nodeKeys = new WeakMap<Element, string>();
|
|
224
|
+
let nextNode = 0;
|
|
225
|
+
const keyForNode = (el: Element): string => {
|
|
226
|
+
const hfId = el.getAttribute("data-hf-id");
|
|
227
|
+
if (hfId != null && hfId !== "") return `hfid:${hfId}`;
|
|
228
|
+
const existing = nodeKeys.get(el);
|
|
229
|
+
if (existing != null) return existing;
|
|
230
|
+
const key = `node:${nextNode++}`;
|
|
231
|
+
nodeKeys.set(el, key);
|
|
232
|
+
return key;
|
|
233
|
+
};
|
|
207
234
|
return (sel) => {
|
|
208
235
|
if (!doc) return sel;
|
|
209
236
|
try {
|
|
210
|
-
|
|
237
|
+
const el = doc.querySelector(sel);
|
|
238
|
+
return el ? keyForNode(el) : sel;
|
|
211
239
|
} catch {
|
|
212
240
|
return sel;
|
|
213
241
|
}
|