@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.
Files changed (35) hide show
  1. package/dist/assets/{index-BITwbxi-.css → index-BrhJl2JY.css} +1 -1
  2. package/dist/assets/index-CVHV-S-B.js +296 -0
  3. package/dist/assets/{index-NIdeStFw.js → index-P52mbTRb.js} +1 -1
  4. package/dist/assets/{index-By4Ku5HI.js → index-zIK7PWvk.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +5 -5
  7. package/src/components/StudioRightPanel.tsx +2 -0
  8. package/src/components/editor/AnimationCard.tsx +8 -24
  9. package/src/components/editor/ComputedTweenNotice.tsx +40 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +5 -24
  11. package/src/components/editor/PropertyPanel.tsx +2 -0
  12. package/src/components/editor/gsapAnimationCallbacks.ts +33 -0
  13. package/src/components/editor/propertyPanelHelpers.ts +2 -0
  14. package/src/contexts/DomEditContext.tsx +4 -0
  15. package/src/hooks/gsapDragCommit.ts +90 -3
  16. package/src/hooks/gsapRuntimeBridge.ts +70 -19
  17. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  18. package/src/hooks/gsapRuntimeKeyframes.ts +198 -123
  19. package/src/hooks/gsapScriptCommitTypes.ts +11 -0
  20. package/src/hooks/serializeByKey.test.ts +83 -0
  21. package/src/hooks/serializeByKey.ts +33 -0
  22. package/src/hooks/useDomEditSession.ts +2 -0
  23. package/src/hooks/useDomGeometryCommits.ts +9 -1
  24. package/src/hooks/useGsapAnimationOps.ts +5 -1
  25. package/src/hooks/useGsapAwareEditing.ts +21 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +21 -1
  27. package/src/hooks/useGsapScriptCommits.ts +33 -5
  28. package/src/hooks/useGsapTweenCache.ts +49 -7
  29. package/src/utils/sdkShadow.test.ts +187 -1
  30. package/src/utils/sdkShadow.ts +67 -10
  31. package/src/utils/sdkShadowGsapFidelity.ts +46 -18
  32. package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
  33. package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
  34. package/src/utils/sdkShadowNumeric.ts +11 -0
  35. package/dist/assets/index-CKd39gmy.js +0 -296
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { openComposition } from "@hyperframes/sdk";
3
+ import {
4
+ resolveKeyframeIndexByPercentage,
5
+ keyframeOpToEditOp,
6
+ gsapKeyframeFidelityMismatches,
7
+ runShadowGsapKeyframeFidelity,
8
+ type ShadowKeyframeOp,
9
+ } from "./sdkShadowGsapKeyframe";
10
+ import { runShadowDispatch } from "./sdkShadow";
11
+ import type { PatchOperation } from "./sourcePatcher";
12
+
13
+ // Capture sdk_shadow_dispatch telemetry.
14
+ const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
15
+ vi.mock("./studioTelemetry", () => ({
16
+ trackStudioEvent: (event: string, props: Record<string, unknown>) =>
17
+ trackedEvents.push({ event, props }),
18
+ }));
19
+ // STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
20
+ // runners are active here without mocking the availability module.
21
+
22
+ beforeEach(() => {
23
+ trackedEvents.length = 0;
24
+ });
25
+ const lastShadow = () =>
26
+ trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
27
+
28
+ const ANIM_ID = "#hero-to-0-position";
29
+
30
+ function gsapHtml(scriptBody: string): string {
31
+ return /* html */ `<!DOCTYPE html><html><body>
32
+ <div data-hf-id="hf-hero" id="hero" class="clip">x</div>
33
+ <script>
34
+ ${scriptBody}
35
+ window.__timelines = [tl];
36
+ </script>
37
+ </body></html>`;
38
+ }
39
+
40
+ const KF_SCRIPT = `
41
+ const tl = gsap.timeline({ paused: true });
42
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
43
+
44
+ // A script body string (not full HTML) for the index-resolution helpers.
45
+ const KF_SCRIPT_BODY = KF_SCRIPT;
46
+ const DUP_SCRIPT_BODY = `
47
+ const tl = gsap.timeline({ paused: true });
48
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
49
+
50
+ describe("resolveKeyframeIndexByPercentage", () => {
51
+ it("resolves a unique percentage to its 0-based index", () => {
52
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
53
+ keyframeIndex: 1,
54
+ });
55
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
56
+ keyframeIndex: 2,
57
+ });
58
+ });
59
+
60
+ it("matches within ~0.001 tolerance", () => {
61
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
62
+ 1,
63
+ );
64
+ });
65
+
66
+ it("returns null with not_found when no percentage matches", () => {
67
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
68
+ keyframeIndex: null,
69
+ reason: "not_found",
70
+ });
71
+ });
72
+
73
+ it("returns null with no_keyframes for an unknown animation", () => {
74
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
75
+ keyframeIndex: null,
76
+ reason: "no_keyframes",
77
+ });
78
+ });
79
+
80
+ it("returns null with no_keyframes when script is empty", () => {
81
+ expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
82
+ });
83
+
84
+ it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
85
+ expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
86
+ keyframeIndex: null,
87
+ reason: "ambiguous",
88
+ });
89
+ });
90
+
91
+ // Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
92
+ // "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
93
+ // the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
94
+ it("falls back from a -from- id to the -to- tween", () => {
95
+ const fromId = ANIM_ID.replace("-to-", "-from-");
96
+ expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
97
+ keyframeIndex: 1,
98
+ });
99
+ });
100
+ });
101
+
102
+ describe("keyframeOpToEditOp", () => {
103
+ it("maps add → addGsapKeyframe with position = percentage", () => {
104
+ const op: ShadowKeyframeOp = {
105
+ kind: "add",
106
+ animationId: ANIM_ID,
107
+ percentage: 25,
108
+ properties: { x: 50 },
109
+ };
110
+ expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
111
+ op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
112
+ });
113
+ });
114
+
115
+ it("maps remove → removeGsapKeyframe with resolved index", () => {
116
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
117
+ expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
118
+ op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
119
+ });
120
+ });
121
+
122
+ it("returns null op + reason when remove percentage is ambiguous", () => {
123
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
124
+ expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
125
+ });
126
+ });
127
+
128
+ describe("gsapKeyframeFidelityMismatches", () => {
129
+ it("reports no mismatches when keyframe arrays match", () => {
130
+ expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
131
+ });
132
+
133
+ it("reports a keyframes mismatch when arrays diverge", () => {
134
+ const other = `
135
+ const tl = gsap.timeline({ paused: true });
136
+ tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
137
+ const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
138
+ expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe("runShadowGsapKeyframeFidelity (add)", () => {
143
+ it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
144
+ const beforeHtml = gsapHtml(KF_SCRIPT);
145
+ // server script unchanged (server "failed" to add the 25% keyframe) → drift
146
+ const session = await openComposition(beforeHtml);
147
+ const serverScript = session
148
+ .serialize()
149
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
150
+ expect(serverScript).toBeTruthy();
151
+ const op: ShadowKeyframeOp = {
152
+ kind: "add",
153
+ animationId: ANIM_ID,
154
+ percentage: 25,
155
+ properties: { x: 50 },
156
+ };
157
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
158
+ const props = lastShadow();
159
+ expect(props?.op).toBe("gsap_keyframe");
160
+ expect(props?.dispatched).toBe(true);
161
+ expect(props?.mismatchCount).toBe(1);
162
+ });
163
+
164
+ it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
165
+ const beforeHtml = gsapHtml(KF_SCRIPT);
166
+ // Build the server's resulting script by applying the same op via the SDK.
167
+ const serverSession = await openComposition(beforeHtml);
168
+ serverSession.batch(() =>
169
+ serverSession.dispatch({
170
+ type: "addGsapKeyframe",
171
+ animationId: ANIM_ID,
172
+ position: 25,
173
+ value: { x: 50 },
174
+ }),
175
+ );
176
+ const serverScript = serverSession
177
+ .serialize()
178
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
179
+ const op: ShadowKeyframeOp = {
180
+ kind: "add",
181
+ animationId: ANIM_ID,
182
+ percentage: 25,
183
+ properties: { x: 50 },
184
+ };
185
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
186
+ const props = lastShadow();
187
+ expect(props?.op).toBe("gsap_keyframe");
188
+ expect(props?.dispatched).toBe(true);
189
+ expect(props?.mismatchCount).toBe(0);
190
+ });
191
+ });
192
+
193
+ describe("runShadowGsapKeyframeFidelity (remove)", () => {
194
+ it("no-ops with reason when remove percentage is ambiguous", async () => {
195
+ const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
196
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
197
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
198
+ const props = lastShadow();
199
+ expect(props?.op).toBe("gsap_keyframe");
200
+ expect(props?.dispatched).toBe(false);
201
+ expect(props?.reason).toBe("ambiguous");
202
+ });
203
+
204
+ it("dispatches a resolved remove and diffs", async () => {
205
+ const beforeHtml = gsapHtml(KF_SCRIPT);
206
+ const serverSession = await openComposition(beforeHtml);
207
+ serverSession.batch(() =>
208
+ serverSession.dispatch({
209
+ type: "removeGsapKeyframe",
210
+ animationId: ANIM_ID,
211
+ keyframeIndex: 1,
212
+ }),
213
+ );
214
+ const serverScript = serverSession
215
+ .serialize()
216
+ .match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
217
+ const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
218
+ await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
219
+ const props = lastShadow();
220
+ expect(props?.op).toBe("gsap_keyframe");
221
+ expect(props?.dispatched).toBe(true);
222
+ expect(props?.mismatchCount).toBe(0);
223
+ });
224
+ });
225
+
226
+ describe("runShadowGsapKeyframeFidelity (guards)", () => {
227
+ it("skips when there is no server script", async () => {
228
+ const op: ShadowKeyframeOp = {
229
+ kind: "add",
230
+ animationId: ANIM_ID,
231
+ percentage: 25,
232
+ properties: { x: 50 },
233
+ };
234
+ await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
235
+ expect(lastShadow()).toBeUndefined();
236
+ });
237
+ });
238
+
239
+ describe("runShadowDispatch unmapped-type guard", () => {
240
+ const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
241
+ <div data-hf-id="hf-box" style="color: red;">Hi</div>
242
+ </body></html>`;
243
+
244
+ it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
245
+ const session = await openComposition(ELEMENT_HTML);
246
+ // PatchOperation.type is a closed union today; cast to exercise the defensive
247
+ // guard for a future unmapped type.
248
+ const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
249
+ runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
250
+ const props = lastShadow();
251
+ expect(props?.op).toBe("property");
252
+ expect(props?.dispatched).toBe(false);
253
+ expect(props?.reason).toBe("unmapped_type");
254
+ expect(props?.type).toBe("future-op");
255
+ });
256
+
257
+ it("dispatches normally for known PatchOperation types", async () => {
258
+ const session = await openComposition(ELEMENT_HTML);
259
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
260
+ runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
261
+ const props = lastShadow();
262
+ expect(props?.dispatched).toBe(true);
263
+ expect(props?.reason).toBeUndefined();
264
+ });
265
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7
3
+ * shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the
4
+ * shared files stay untouched (only additive imports) and the studio 600-line cap
5
+ * holds.
6
+ *
7
+ * Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there
8
+ * is no existence-parity path here. Instead we compare the two writers' output:
9
+ * open a fresh SDK doc from the server's pre-op file, dispatch the equivalent
10
+ * keyframe op, serialize, and diff the SDK's GSAP script against the server's
11
+ * resulting script.
12
+ *
13
+ * gsapFidelityMismatches (reused) matches tweens by resolved target element +
14
+ * method + position and diffs tween-level fields — but it does NOT look inside a
15
+ * tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff,
16
+ * layered on top of the reused tween-level diff, matched by the GSAP animation id.
17
+ *
18
+ * SDK mapping (main, pre PR #1498 percentage-variant):
19
+ * add → addGsapKeyframe{animationId, position: percentage, value: properties}
20
+ * remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is
21
+ * percentage-based, so we resolve percentage → index against the pre-op
22
+ * script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and
23
+ * no-op on ambiguity (duplicate-percentage keyframes can't be told
24
+ * apart by percentage — landmine from PR #1498).
25
+ */
26
+
27
+ import { openComposition } from "@hyperframes/sdk";
28
+ import type { EditOp } from "@hyperframes/sdk";
29
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
30
+ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
31
+ import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
32
+ import { trackStudioEvent } from "./studioTelemetry";
33
+ import type { SdkShadowMismatch } from "./sdkShadow";
34
+ import {
35
+ extractGsapScript,
36
+ gsapFidelityMismatches,
37
+ makeSelectorResolver,
38
+ } from "./sdkShadowGsapFidelity";
39
+
40
+ // Match the GSAP writer's percentage equality tolerance so a remove resolves to
41
+ // the same keyframe the server would pick (writer rounds to ~3 decimals).
42
+ const KF_PERCENT_TOLERANCE = 0.001;
43
+
44
+ export type ShadowKeyframeOp =
45
+ | {
46
+ kind: "add";
47
+ animationId: string;
48
+ percentage: number;
49
+ properties: Record<string, number | string>;
50
+ }
51
+ | { kind: "remove"; animationId: string; percentage: number };
52
+
53
+ // ─── percentage → SDK op mapping ──────────────────────────────────────────────
54
+
55
+ function findAnimationKeyframes(
56
+ script: string,
57
+ animationId: string,
58
+ ): GsapPercentageKeyframe[] | null {
59
+ const parsed = parseGsapScriptAcorn(script);
60
+ // Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo
61
+ // tween's derived id may be normalized to "-to-" on write, so fall back to the
62
+ // converted id when the exact one isn't found — otherwise the keyframe diff
63
+ // goes blind (both scripts resolve null → falsely "clean") on converted tweens.
64
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
65
+ const anim =
66
+ parsed.animations.find((a) => a.id === animationId) ??
67
+ parsed.animations.find((a) => a.id === convertedId);
68
+ return anim?.keyframes?.keyframes ?? null;
69
+ }
70
+
71
+ export interface KeyframeRemoveResolution {
72
+ /** Resolved 0-based index, or null when it can't be safely resolved. */
73
+ keyframeIndex: number | null;
74
+ /** Why no index — for telemetry when keyframeIndex is null. */
75
+ reason?: "no_keyframes" | "not_found" | "ambiguous";
76
+ }
77
+
78
+ /**
79
+ * Resolve a percentage-based remove to a keyframe index against the pre-op
80
+ * script. Returns null index (with a reason) when there are no keyframes, the
81
+ * percentage matches none, or — per the PR #1498 landmine — more than one
82
+ * keyframe shares the percentage (can't be disambiguated by percentage alone).
83
+ * Pure + exported so the mapping is unit-testable without an SDK session.
84
+ */
85
+ export function resolveKeyframeIndexByPercentage(
86
+ script: string | null | undefined,
87
+ animationId: string,
88
+ percentage: number,
89
+ ): KeyframeRemoveResolution {
90
+ if (!script) return { keyframeIndex: null, reason: "no_keyframes" };
91
+ const kfs = findAnimationKeyframes(script, animationId);
92
+ if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" };
93
+ const matches: number[] = [];
94
+ for (let i = 0; i < kfs.length; i++) {
95
+ if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i);
96
+ }
97
+ if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" };
98
+ if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" };
99
+ return { keyframeIndex: matches[0] };
100
+ }
101
+
102
+ /**
103
+ * Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op
104
+ * script to resolve percentage → index; returns null (with a reason) when the
105
+ * index can't be safely resolved so the caller can emit a no-op-with-reason
106
+ * event instead of dispatching the wrong keyframe.
107
+ */
108
+ export function keyframeOpToEditOp(
109
+ op: ShadowKeyframeOp,
110
+ beforeScript: string | null | undefined,
111
+ ): { op: EditOp } | { op: null; reason: string } {
112
+ if (op.kind === "add") {
113
+ return {
114
+ op: {
115
+ type: "addGsapKeyframe",
116
+ animationId: op.animationId,
117
+ position: op.percentage,
118
+ value: op.properties,
119
+ },
120
+ };
121
+ }
122
+ const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage);
123
+ if (resolved.keyframeIndex === null) {
124
+ return { op: null, reason: resolved.reason ?? "unresolved" };
125
+ }
126
+ return {
127
+ op: {
128
+ type: "removeGsapKeyframe",
129
+ animationId: op.animationId,
130
+ keyframeIndex: resolved.keyframeIndex,
131
+ },
132
+ };
133
+ }
134
+
135
+ // ─── Keyframe-aware fidelity diff ─────────────────────────────────────────────
136
+
137
+ function canonicalKeyframe(kf: GsapPercentageKeyframe): string {
138
+ const props: Record<string, unknown> = {};
139
+ for (const key of Object.keys(kf.properties).sort()) {
140
+ const v = kf.properties[key];
141
+ props[key] =
142
+ typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
143
+ }
144
+ return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props });
145
+ }
146
+
147
+ function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string {
148
+ if (!kfs) return "[]";
149
+ return JSON.stringify(
150
+ [...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe),
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a
156
+ * keyframe-array comparison for the targeted animation (which the tween-level
157
+ * diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and
158
+ * server keyframe arrays diverge canonically.
159
+ */
160
+ export function gsapKeyframeFidelityMismatches(
161
+ sdkScript: string,
162
+ serverScript: string,
163
+ animationId: string,
164
+ resolveSelector?: (sel: string) => string,
165
+ ): SdkShadowMismatch[] {
166
+ const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector);
167
+ const sdkKfs = findAnimationKeyframes(sdkScript, animationId);
168
+ const serverKfs = findAnimationKeyframes(serverScript, animationId);
169
+ const sdkCanon = canonicalKeyframes(sdkKfs);
170
+ const serverCanon = canonicalKeyframes(serverKfs);
171
+ if (sdkCanon !== serverCanon) {
172
+ mismatches.push({
173
+ kind: "value_mismatch",
174
+ hfId: animationId,
175
+ property: "keyframes",
176
+ expected: serverCanon,
177
+ actual: sdkCanon,
178
+ });
179
+ }
180
+ return mismatches;
181
+ }
182
+
183
+ // ─── Telemetry runner ─────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file,
187
+ * apply the equivalent keyframe op, serialize, and diff against the server's
188
+ * resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async,
189
+ * fire-and-forget; server stays authoritative. No-op when shadow is disabled.
190
+ */
191
+ export async function runShadowGsapKeyframeFidelity(
192
+ beforeHtml: string | null | undefined,
193
+ op: ShadowKeyframeOp,
194
+ serverScript: string | null | undefined,
195
+ ): Promise<void> {
196
+ if (!STUDIO_SDK_SHADOW_ENABLED) return;
197
+ // No server script to diff against → skip the (costly) openComposition.
198
+ if (!serverScript || !beforeHtml) return;
199
+ const beforeScript = extractGsapScript(beforeHtml);
200
+ const mapped = keyframeOpToEditOp(op, beforeScript);
201
+ if (mapped.op === null) {
202
+ // Ambiguous / not-found percentage: don't dispatch the wrong keyframe.
203
+ trackStudioEvent("sdk_shadow_dispatch", {
204
+ op: "gsap_keyframe",
205
+ dispatched: false,
206
+ reason: mapped.reason,
207
+ mismatchCount: 0,
208
+ });
209
+ return;
210
+ }
211
+ const editOp = mapped.op;
212
+ try {
213
+ const session = await openComposition(beforeHtml);
214
+ const verdict = session.can(editOp);
215
+ if (!verdict.ok) {
216
+ trackStudioEvent("sdk_shadow_dispatch", {
217
+ op: "gsap_keyframe",
218
+ dispatched: false,
219
+ reason: "cannot_dispatch",
220
+ code: verdict.code,
221
+ mismatchCount: 0,
222
+ });
223
+ return;
224
+ }
225
+ session.batch(() => session.dispatch(editOp));
226
+ const sdkScript = extractGsapScript(session.serialize());
227
+ if (sdkScript == null) {
228
+ trackStudioEvent("sdk_shadow_dispatch", {
229
+ op: "gsap_keyframe",
230
+ dispatched: false,
231
+ reason: "no_sdk_script",
232
+ mismatchCount: 0,
233
+ });
234
+ return;
235
+ }
236
+ const mismatches = gsapKeyframeFidelityMismatches(
237
+ sdkScript,
238
+ serverScript,
239
+ op.animationId,
240
+ makeSelectorResolver(beforeHtml),
241
+ );
242
+ trackStudioEvent("sdk_shadow_dispatch", {
243
+ op: "gsap_keyframe",
244
+ dispatched: true,
245
+ mismatchCount: mismatches.length,
246
+ mismatches: JSON.stringify(mismatches),
247
+ });
248
+ } catch (err) {
249
+ trackStudioEvent("sdk_shadow_dispatch", {
250
+ op: "gsap_keyframe",
251
+ dispatched: false,
252
+ reason: "fidelity_error",
253
+ error: String(err),
254
+ mismatchCount: 0,
255
+ });
256
+ }
257
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Relative-epsilon numeric equality shared by the shadow diffs (timing parity +
3
+ * GSAP value fidelity). Both writers round-trip durations/positions through JS
4
+ * number formatting, so a value like 3.1 can read back as 3.0999999999999996.
5
+ * Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a
6
+ * real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise.
7
+ */
8
+ export function relEqual(a: number, b: number): boolean {
9
+ if (a === b) return true;
10
+ return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
11
+ }