mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe) (#1509)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5aca3ad770
commit
4b4a3eb63d
@@ -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 = (
|
||||
|
||||
@@ -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<string, number | string>,
|
||||
) => {
|
||||
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}%`);
|
||||
|
||||
@@ -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 } } });
|
||||
}
|
||||
|
||||
@@ -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<string> = new Set([
|
||||
"inline-style",
|
||||
"text-content",
|
||||
"attribute",
|
||||
"html-attribute",
|
||||
]);
|
||||
|
||||
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
||||
const result: EditOp[] = [];
|
||||
const styles: Record<string, string | null> = {};
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown> }> = [];
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
||||
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 */ `<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="hf-hero" id="hero" class="clip">x</div>
|
||||
<script>
|
||||
${scriptBody}
|
||||
window.__timelines = [tl];
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
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(/<script\b[^>]*>([\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(/<script\b[^>]*>([\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(/<script\b[^>]*>([\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 */ `<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="hf-box" style="color: red;">Hi</div>
|
||||
</body></html>`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, number | string>;
|
||||
}
|
||||
| { 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 `</script[^>]*>` (HTML5 ignores junk before `>`).
|
||||
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
||||
if (!scripts) return null;
|
||||
for (const block of scripts) {
|
||||
const body = block.replace(/^<script\b[^>]*>/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<string, unknown> = {};
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user