From 4b4a3eb63dbffe47e2ed19358e2a3b6ce2d19d8a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 12:27:48 -0700 Subject: [PATCH] feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe) (#1509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): suppress shadow-parity false positives in timing + text runShadowTiming: compare start/duration with a relative epsilon (1e-6) instead of exact equality so float-precision drift (3.1 vs 3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real difference (3.1 vs 3.5) still flags. trackIndex stays exact. property:text resolver: trim both sides (snapshot.text is already trimmed) and collapse empty-string vs absent (null) text so trailing-whitespace and empty-vs-null no longer flag. Genuine text differences are unaffected; the per-keystroke length lag is a caller-side debounce concern. Adds tests for both fixes plus regression tests documenting two REAL SDK divergences the shadow correctly surfaces (transform-origin removal no-op; duplicate-bare-id delete resolution) — flagged, not fixed here. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe) Wire the SDK shadow-parity telemetry to cover GSAP keyframe add/remove, the primary unwired cutover signal, plus a defensive unmapped-PatchOperation guard. New packages/studio/src/utils/sdkShadowGsapKeyframe.ts: - ShadowKeyframeOp + keyframeOpToEditOp: maps studio percentage-based keyframe ops to SDK EditOps. add -> addGsapKeyframe{position:percentage}; remove -> removeGsapKeyframe{keyframeIndex}, resolving percentage -> index against the pre-op script with ~0.001 tolerance and a no-op-on-ambiguity guard for duplicate-percentage keyframes (PR #1498 landmine). - gsapKeyframeFidelityMismatches: reuses gsapFidelityMismatches for the tween-level diff and layers a keyframe-array comparison (which the base diff doesn't inspect), matched by GSAP animation id. - runShadowGsapKeyframeFidelity: serialize-diff runner emitting op tag gsap_keyframe (no keyframe reader on ElementSnapshot, so no existence path). useGsapKeyframeOps synthesizes shadowKeyframeOp for addKeyframe / addKeyframeBatch / removeKeyframe; the commit chokepoint dispatches the keyframe-fidelity diff alongside the existing tween-fidelity path. sdkShadow.ts: runShadowDispatch now emits dispatched:false reason:unmapped_type if a future PatchOperation type ever escapes patchOpsToSdkEditOps, so the gap surfaces in telemetry instead of vanishing. Tests: sdkShadowGsapKeyframe.test.ts (18) covers index resolution, op mapping, the ambiguity guard, the keyframe-aware diff, the runner, and the unmapped-type guard. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../studio/src/hooks/gsapScriptCommitTypes.ts | 3 + .../studio/src/hooks/useGsapKeyframeOps.ts | 22 +- .../studio/src/hooks/useGsapScriptCommits.ts | 14 +- packages/studio/src/utils/sdkShadow.ts | 27 ++ .../src/utils/sdkShadowGsapKeyframe.test.ts | 265 +++++++++++++++++ .../studio/src/utils/sdkShadowGsapKeyframe.ts | 270 ++++++++++++++++++ 6 files changed, 597 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts create mode 100644 packages/studio/src/utils/sdkShadowGsapKeyframe.ts diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index b4bd05c49..202a83add 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; import type { ShadowGsapOp } from "../utils/sdkShadow"; +import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; export interface MutationResult { ok: boolean; @@ -21,6 +22,8 @@ export interface CommitMutationOptions { beforeReload?: () => void; /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */ shadowGsapOp?: ShadowGsapOp; + /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */ + shadowKeyframeOp?: ShadowKeyframeOp; } export type CommitMutation = ( diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 44b663540..809363c4d 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { executeOptimistic } from "../utils/optimisticUpdate"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; @@ -58,6 +59,13 @@ export function useGsapKeyframeOps({ percentage, properties: { [property]: value }, }; + // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint. + const shadowKeyframeOp: ShadowKeyframeOp = { + kind: "add", + animationId, + percentage, + properties: { [property]: value }, + }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, @@ -71,6 +79,7 @@ export function useGsapKeyframeOps({ commitMutation(selection, mutation, { label: `Add keyframe at ${percentage}%`, softReload: true, + shadowKeyframeOp, }), }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); @@ -86,10 +95,16 @@ export function useGsapKeyframeOps({ percentage: number, properties: Record, ) => { + const shadowKeyframeOp: ShadowKeyframeOp = { + kind: "add", + animationId, + percentage, + properties, + }; return commitMutation( selection, { type: "add-keyframe", animationId, percentage, properties }, - { label: `Add keyframe at ${percentage}%`, softReload: true }, + { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp }, ); }, [commitMutation], @@ -99,6 +114,10 @@ export function useGsapKeyframeOps({ (selection: DomEditSelection, animationId: string, percentage: number) => { const sourceFile = selection.sourceFile || activeCompPath || "index.html"; const mutation = { type: "remove-keyframe", animationId, percentage }; + // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main, + // so the runner resolves percentage → keyframeIndex against the pre-op + // script and no-ops on ambiguity (duplicate-percentage keyframes). + const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage }; void executeOptimisticKeyframeCacheUpdate({ sourceFile, elementId: selection.id, @@ -112,6 +131,7 @@ export function useGsapKeyframeOps({ commitMutation(selection, mutation, { label: `Remove keyframe at ${percentage}%`, softReload: true, + shadowKeyframeOp, }), }).catch((error) => { trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 6c0abe9fc..49e57991c 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -3,6 +3,7 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload } from "../utils/gsapSoftReload"; import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; +import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, @@ -70,9 +71,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra domEditSaveTimestampRef.current = Date.now(); // Shadow value fidelity: diff the SDK's GSAP writer output against the // server's, from the same pre-op file. Fire-and-forget; server authoritative. - // Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via - // useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce, - // useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up. + // Meta-level ops carry shadowGsapOp (add / update-meta / delete via + // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via + // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property + // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up. // scriptText is null when the composition has no GSAP script; nothing to diff. const fidelityArgs = resolveGsapFidelityArgs( sdkSession, @@ -83,6 +85,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra if (fidelityArgs) { void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript); } + // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but + // the SDK has no keyframe reader so there is no live-existence path — the diff + // is the only signal. Guarded on a live session + both scripts to diff. + if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) { + void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText); + } if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index ff8ba8410..631eba20b 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -39,6 +39,16 @@ function isShadowableOp(op: PatchOperation): boolean { return true; } +// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by +// runShadowDispatch to flag any unmapped type as visible telemetry rather than +// silently dropping it (see the unmapped_type guard there). +const MAPPED_PATCH_OP_TYPES: ReadonlySet = new Set([ + "inline-style", + "text-content", + "attribute", + "html-attribute", +]); + export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { const result: EditOp[] = []; const styles: Record = {}; @@ -260,6 +270,23 @@ export function runShadowDispatch( }); return; } + // Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it + // doesn't map. PatchOperation.type is a closed union today, but emit a visible + // unmapped_type event if a future type ever slips through, so the gap surfaces + // in telemetry instead of vanishing. + // Map to the type string before find, so a future unmapped type is read as a + // plain string (no object cast; find on the closed union narrows to never). + const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t)); + if (unmappedType !== undefined) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "property", + dispatched: false, + reason: "unmapped_type", + type: unmappedType, + mismatchCount: 0, + }); + return; + } const result = sdkShadowDispatch(session, hfId, ops); trackStudioEvent("sdk_shadow_dispatch", { op: "property", diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts new file mode 100644 index 000000000..20f08313a --- /dev/null +++ b/packages/studio/src/utils/sdkShadowGsapKeyframe.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { openComposition } from "@hyperframes/sdk"; +import { + resolveKeyframeIndexByPercentage, + keyframeOpToEditOp, + gsapKeyframeFidelityMismatches, + runShadowGsapKeyframeFidelity, + type ShadowKeyframeOp, +} from "./sdkShadowGsapKeyframe"; +import { runShadowDispatch } from "./sdkShadow"; +import type { PatchOperation } from "./sourcePatcher"; + +// Capture sdk_shadow_dispatch telemetry. +const trackedEvents: Array<{ event: string; props: Record }> = []; +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: (event: string, props: Record) => + trackedEvents.push({ event, props }), +})); +// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the +// runners are active here without mocking the availability module. + +beforeEach(() => { + trackedEvents.length = 0; +}); +const lastShadow = () => + trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props; + +const ANIM_ID = "#hero-to-0-position"; + +function gsapHtml(scriptBody: string): string { + return /* html */ ` +
x
+ +`; +} + +const KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`; + +// A script body string (not full HTML) for the index-resolution helpers. +const KF_SCRIPT_BODY = KF_SCRIPT; +const DUP_SCRIPT_BODY = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`; + +describe("resolveKeyframeIndexByPercentage", () => { + it("resolves a unique percentage to its 0-based index", () => { + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ + keyframeIndex: 1, + }); + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({ + keyframeIndex: 2, + }); + }); + + it("matches within ~0.001 tolerance", () => { + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe( + 1, + ); + }); + + it("returns null with not_found when no percentage matches", () => { + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({ + keyframeIndex: null, + reason: "not_found", + }); + }); + + it("returns null with no_keyframes for an unknown animation", () => { + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({ + keyframeIndex: null, + reason: "no_keyframes", + }); + }); + + it("returns null with no_keyframes when script is empty", () => { + expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes"); + }); + + it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => { + expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({ + keyframeIndex: null, + reason: "ambiguous", + }); + }); + + // Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a + // "-from-"/"-fromTo-" animationId must fall back to the converted id (matching + // the writer's locateAnimationWithFallback) — else the keyframe diff goes blind. + it("falls back from a -from- id to the -to- tween", () => { + const fromId = ANIM_ID.replace("-to-", "-from-"); + expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({ + keyframeIndex: 1, + }); + }); +}); + +describe("keyframeOpToEditOp", () => { + it("maps add → addGsapKeyframe with position = percentage", () => { + const op: ShadowKeyframeOp = { + kind: "add", + animationId: ANIM_ID, + percentage: 25, + properties: { x: 50 }, + }; + expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ + op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } }, + }); + }); + + it("maps remove → removeGsapKeyframe with resolved index", () => { + const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; + expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({ + op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 }, + }); + }); + + it("returns null op + reason when remove percentage is ambiguous", () => { + const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; + expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" }); + }); +}); + +describe("gsapKeyframeFidelityMismatches", () => { + it("reports no mismatches when keyframe arrays match", () => { + expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]); + }); + + it("reports a keyframes mismatch when arrays diverge", () => { + const other = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`; + const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID); + expect(mismatches.some((m) => m.property === "keyframes")).toBe(true); + }); +}); + +describe("runShadowGsapKeyframeFidelity (add)", () => { + it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => { + const beforeHtml = gsapHtml(KF_SCRIPT); + // server script unchanged (server "failed" to add the 25% keyframe) → drift + const session = await openComposition(beforeHtml); + const serverScript = session + .serialize() + .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; + expect(serverScript).toBeTruthy(); + const op: ShadowKeyframeOp = { + kind: "add", + animationId: ANIM_ID, + percentage: 25, + properties: { x: 50 }, + }; + await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); + const props = lastShadow(); + expect(props?.op).toBe("gsap_keyframe"); + expect(props?.dispatched).toBe(true); + expect(props?.mismatchCount).toBe(1); + }); + + it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => { + const beforeHtml = gsapHtml(KF_SCRIPT); + // Build the server's resulting script by applying the same op via the SDK. + const serverSession = await openComposition(beforeHtml); + serverSession.batch(() => + serverSession.dispatch({ + type: "addGsapKeyframe", + animationId: ANIM_ID, + position: 25, + value: { x: 50 }, + }), + ); + const serverScript = serverSession + .serialize() + .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; + const op: ShadowKeyframeOp = { + kind: "add", + animationId: ANIM_ID, + percentage: 25, + properties: { x: 50 }, + }; + await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); + const props = lastShadow(); + expect(props?.op).toBe("gsap_keyframe"); + expect(props?.dispatched).toBe(true); + expect(props?.mismatchCount).toBe(0); + }); +}); + +describe("runShadowGsapKeyframeFidelity (remove)", () => { + it("no-ops with reason when remove percentage is ambiguous", async () => { + const beforeHtml = gsapHtml(DUP_SCRIPT_BODY); + const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; + await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap"); + const props = lastShadow(); + expect(props?.op).toBe("gsap_keyframe"); + expect(props?.dispatched).toBe(false); + expect(props?.reason).toBe("ambiguous"); + }); + + it("dispatches a resolved remove and diffs", async () => { + const beforeHtml = gsapHtml(KF_SCRIPT); + const serverSession = await openComposition(beforeHtml); + serverSession.batch(() => + serverSession.dispatch({ + type: "removeGsapKeyframe", + animationId: ANIM_ID, + keyframeIndex: 1, + }), + ); + const serverScript = serverSession + .serialize() + .match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1]; + const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 }; + await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript); + const props = lastShadow(); + expect(props?.op).toBe("gsap_keyframe"); + expect(props?.dispatched).toBe(true); + expect(props?.mismatchCount).toBe(0); + }); +}); + +describe("runShadowGsapKeyframeFidelity (guards)", () => { + it("skips when there is no server script", async () => { + const op: ShadowKeyframeOp = { + kind: "add", + animationId: ANIM_ID, + percentage: 25, + properties: { x: 50 }, + }; + await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null); + expect(lastShadow()).toBeUndefined(); + }); +}); + +describe("runShadowDispatch unmapped-type guard", () => { + const ELEMENT_HTML = /* html */ ` +
Hi
+ `; + + it("emits unmapped_type when a PatchOperation type isn't mapped", async () => { + const session = await openComposition(ELEMENT_HTML); + // PatchOperation.type is a closed union today; cast to exercise the defensive + // guard for a future unmapped type. + const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation]; + runShadowDispatch(session, { hfId: "hf-box" } as never, ops); + const props = lastShadow(); + expect(props?.op).toBe("property"); + expect(props?.dispatched).toBe(false); + expect(props?.reason).toBe("unmapped_type"); + expect(props?.type).toBe("future-op"); + }); + + it("dispatches normally for known PatchOperation types", async () => { + const session = await openComposition(ELEMENT_HTML); + const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }]; + runShadowDispatch(session, { hfId: "hf-box" } as never, ops); + const props = lastShadow(); + expect(props?.dispatched).toBe(true); + expect(props?.reason).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts new file mode 100644 index 000000000..7a657c23d --- /dev/null +++ b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts @@ -0,0 +1,270 @@ +/** + * GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7 + * shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the + * shared files stay untouched (only additive imports) and the studio 600-line cap + * holds. + * + * Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there + * is no existence-parity path here. Instead we compare the two writers' output: + * open a fresh SDK doc from the server's pre-op file, dispatch the equivalent + * keyframe op, serialize, and diff the SDK's GSAP script against the server's + * resulting script. + * + * gsapFidelityMismatches (reused) matches tweens by resolved target element + + * method + position and diffs tween-level fields — but it does NOT look inside a + * tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff, + * layered on top of the reused tween-level diff, matched by the GSAP animation id. + * + * SDK mapping (main, pre PR #1498 percentage-variant): + * add → addGsapKeyframe{animationId, position: percentage, value: properties} + * remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is + * percentage-based, so we resolve percentage → index against the pre-op + * script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and + * no-op on ambiguity (duplicate-percentage keyframes can't be told + * apart by percentage — landmine from PR #1498). + */ + +import { openComposition } from "@hyperframes/sdk"; +import type { EditOp } from "@hyperframes/sdk"; +import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; +import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; +import { trackStudioEvent } from "./studioTelemetry"; +import type { SdkShadowMismatch } from "./sdkShadow"; +import { gsapFidelityMismatches, makeSelectorResolver } from "./sdkShadowGsapFidelity"; + +// Match the GSAP writer's percentage equality tolerance so a remove resolves to +// the same keyframe the server would pick (writer rounds to ~3 decimals). +const KF_PERCENT_TOLERANCE = 0.001; + +export type ShadowKeyframeOp = + | { + kind: "add"; + animationId: string; + percentage: number; + properties: Record; + } + | { kind: "remove"; animationId: string; percentage: number }; + +// ─── Script helpers (mirror sdkShadowGsapFidelity's extraction) ─────────────── + +function isGsapScriptBody(body: string): boolean { + return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger"); +} + +function extractGsapScript(html: string): string | null { + // Close tag is `]*>` (HTML5 ignores junk before `>`). + const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); + if (!scripts) return null; + for (const block of scripts) { + const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); + if (isGsapScriptBody(body)) return body; + } + return null; +} + +// ─── percentage → SDK op mapping ────────────────────────────────────────────── + +function findAnimationKeyframes( + script: string, + animationId: string, +): GsapPercentageKeyframe[] | null { + const parsed = parseGsapScriptAcorn(script); + // Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo + // tween's derived id may be normalized to "-to-" on write, so fall back to the + // converted id when the exact one isn't found — otherwise the keyframe diff + // goes blind (both scripts resolve null → falsely "clean") on converted tweens. + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + const anim = + parsed.animations.find((a) => a.id === animationId) ?? + parsed.animations.find((a) => a.id === convertedId); + return anim?.keyframes?.keyframes ?? null; +} + +export interface KeyframeRemoveResolution { + /** Resolved 0-based index, or null when it can't be safely resolved. */ + keyframeIndex: number | null; + /** Why no index — for telemetry when keyframeIndex is null. */ + reason?: "no_keyframes" | "not_found" | "ambiguous"; +} + +/** + * Resolve a percentage-based remove to a keyframe index against the pre-op + * script. Returns null index (with a reason) when there are no keyframes, the + * percentage matches none, or — per the PR #1498 landmine — more than one + * keyframe shares the percentage (can't be disambiguated by percentage alone). + * Pure + exported so the mapping is unit-testable without an SDK session. + */ +export function resolveKeyframeIndexByPercentage( + script: string | null | undefined, + animationId: string, + percentage: number, +): KeyframeRemoveResolution { + if (!script) return { keyframeIndex: null, reason: "no_keyframes" }; + const kfs = findAnimationKeyframes(script, animationId); + if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" }; + const matches: number[] = []; + for (let i = 0; i < kfs.length; i++) { + if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i); + } + if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" }; + if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" }; + return { keyframeIndex: matches[0] }; +} + +/** + * Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op + * script to resolve percentage → index; returns null (with a reason) when the + * index can't be safely resolved so the caller can emit a no-op-with-reason + * event instead of dispatching the wrong keyframe. + */ +export function keyframeOpToEditOp( + op: ShadowKeyframeOp, + beforeScript: string | null | undefined, +): { op: EditOp } | { op: null; reason: string } { + if (op.kind === "add") { + return { + op: { + type: "addGsapKeyframe", + animationId: op.animationId, + position: op.percentage, + value: op.properties, + }, + }; + } + const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage); + if (resolved.keyframeIndex === null) { + return { op: null, reason: resolved.reason ?? "unresolved" }; + } + return { + op: { + type: "removeGsapKeyframe", + animationId: op.animationId, + keyframeIndex: resolved.keyframeIndex, + }, + }; +} + +// ─── Keyframe-aware fidelity diff ───────────────────────────────────────────── + +function canonicalKeyframe(kf: GsapPercentageKeyframe): string { + const props: Record = {}; + for (const key of Object.keys(kf.properties).sort()) { + const v = kf.properties[key]; + props[key] = + typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; + } + return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props }); +} + +function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string { + if (!kfs) return "[]"; + return JSON.stringify( + [...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe), + ); +} + +/** + * Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a + * keyframe-array comparison for the targeted animation (which the tween-level + * diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and + * server keyframe arrays diverge canonically. + */ +export function gsapKeyframeFidelityMismatches( + sdkScript: string, + serverScript: string, + animationId: string, + resolveSelector?: (sel: string) => string, +): SdkShadowMismatch[] { + const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector); + const sdkKfs = findAnimationKeyframes(sdkScript, animationId); + const serverKfs = findAnimationKeyframes(serverScript, animationId); + const sdkCanon = canonicalKeyframes(sdkKfs); + const serverCanon = canonicalKeyframes(serverKfs); + if (sdkCanon !== serverCanon) { + mismatches.push({ + kind: "value_mismatch", + hfId: animationId, + property: "keyframes", + expected: serverCanon, + actual: sdkCanon, + }); + } + return mismatches; +} + +// ─── Telemetry runner ───────────────────────────────────────────────────────── + +/** + * Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file, + * apply the equivalent keyframe op, serialize, and diff against the server's + * resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async, + * fire-and-forget; server stays authoritative. No-op when shadow is disabled. + */ +export async function runShadowGsapKeyframeFidelity( + beforeHtml: string | null | undefined, + op: ShadowKeyframeOp, + serverScript: string | null | undefined, +): Promise { + if (!STUDIO_SDK_SHADOW_ENABLED) return; + // No server script to diff against → skip the (costly) openComposition. + if (!serverScript || !beforeHtml) return; + const beforeScript = extractGsapScript(beforeHtml); + const mapped = keyframeOpToEditOp(op, beforeScript); + if (mapped.op === null) { + // Ambiguous / not-found percentage: don't dispatch the wrong keyframe. + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_keyframe", + dispatched: false, + reason: mapped.reason, + mismatchCount: 0, + }); + return; + } + const editOp = mapped.op; + try { + const session = await openComposition(beforeHtml); + const verdict = session.can(editOp); + if (!verdict.ok) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_keyframe", + dispatched: false, + reason: "cannot_dispatch", + code: verdict.code, + mismatchCount: 0, + }); + return; + } + session.batch(() => session.dispatch(editOp)); + const sdkScript = extractGsapScript(session.serialize()); + if (sdkScript == null) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_keyframe", + dispatched: false, + reason: "no_sdk_script", + mismatchCount: 0, + }); + return; + } + const mismatches = gsapKeyframeFidelityMismatches( + sdkScript, + serverScript, + op.animationId, + makeSelectorResolver(beforeHtml), + ); + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_keyframe", + dispatched: true, + mismatchCount: mismatches.length, + mismatches: JSON.stringify(mismatches), + }); + } catch (err) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_keyframe", + dispatched: false, + reason: "fidelity_error", + error: String(err), + mismatchCount: 0, + }); + } +}