mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,studio): populate animationIds; shadow GSAP update/delete + value fidelity (#1474)
* feat(sdk,studio): populate animationIds; shadow GSAP update/delete Closes the GSAP shadow gaps. The server's animationId was assumed to live in a separate id-space — it does not: the studio-api read path (T6e) and the SDK both derive tween ids as targetSelector-method-position from the same acorn parser, so server ids are dispatchable in the SDK as-is. SDK: populate ElementSnapshot.animationIds (was a hardcoded stub) from parseGsapScriptAcornForWrite().located, resolving each tween's targetSelector to element hf-ids. Makes the snapshot truthful and enables real GSAP parity. Studio: shadow deleteGsapAnimation (removeGsapTween) and updateGsapMeta (setGsapTween) using the server animationId directly. GSAP add/remove parity now verifies via animationIds (present after add, gone after remove). set is existence-only — the SDK still has no per-tween property reader (value fidelity would need serialize()-script round-trip diffing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): GSAP value fidelity via serialize round-trip diff Closes the last shadow gap: GSAP value fidelity. Existence parity confirmed a tween was created/removed but not that its values (duration/ease/position/ properties) matched the server, since the SDK has no per-tween property reader. runShadowGsapFidelity opens a fresh SDK doc from the server's pre-op file (result.before), applies the same typed op, serializes, and structurally diffs the SDK's GSAP script against the server's resulting script (result.scriptText). Both are re-parsed via parseGsapScriptAcorn, so formatting/whitespace never produces false positives — only real value drift does. gsapFidelityMismatches reports per-field drift and tween presence/absence. Wired at the commitMutation chokepoint (the only place with the server's before+after scripts); handlers pass the typed ShadowGsapOp via CommitMutationOptions.shadowGsapOp. Emits sdk_shadow_dispatch op:gsap_fidelity. Complements the existing live existence shadow (op:gsap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio,sdk): address shadow code-review findings - gsapFidelityMismatches: canonical comparison (sort property keys, numeric- coerce position/duration/values). Server (addAnimationToScript) and SDK (gsapWriterAcorn) are different writers; non-canonical compare flagged key-order / number-vs-string differences as false value drift. - document.ts buildAnimationIdMap: memoize the acorn parse by script text (single-entry). getElements() invalidates on every dispatch, so shadow's frequent dispatches were re-parsing the full GSAP AST each rebuild. Selector resolution still runs per-call (depends on live DOM). - runShadowGsapFidelity: early-bail when serverScript/beforeHtml is empty — skip the costly openComposition. - useSafeGsapCommitMutation: import the shared CommitMutationOptions/ CommitMutation instead of a stale local duplicate (was missing shadowGsapOp). - align extractGsapScript marker set across sdkShadow.ts and document.ts (gsap || __timelines || ScrollTrigger) so both pick the same script. Tests: +2 canonical-compare cases (key-order, number-vs-string → no drift). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio,sdk): fallow gate for #1474 (fidelity diff + test clones) - suppress moderate CRAP on gsapFidelityMismatches and the runShadowGsapTween parity arrow (comparison/parity functions are inherently branchy) - suppress two pre-existing test clones in session.test.ts surfaced by the added animationIds tests (TestPreviewAdapter stub, selectionchange setup) Rebased onto the updated #1473 (no-persist shadow session); inherits the persist-race fix and prior fallow suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(studio): extract GSAP fidelity to its own module (file-size gate) sdkShadow.ts hit 602 lines (CI File size check: max 600). Move the GSAP value-fidelity diff (gsapFidelityMismatches, runShadowGsapFidelity, and their private helpers) into sdkShadowGsapFidelity.ts; re-export from sdkShadow.ts so the import surface is unchanged. sdkShadow.ts now 430 lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio,sdk): address #1474 review feedback - CodeQL js/bad-tag-filter: the GSAP <script> extraction + test regexes now match </script\s*> (whitespace-before-close variant). 3 alerts resolved. - Wiring (Miguel): extract resolveGsapFidelityArgs — a pure, narrowing gate for the commitMutation chokepoint (no non-null assertions) — and unit-test the fire/skip conditions (session, op, before, scriptText). Replaces the inline guard so the wiring decision is covered without rendering the hook. - Property-handler scope (Rames): comment at the chokepoint documenting that only meta-level ops (add/update-meta/delete) carry shadowGsapOp today; per-property and keyframe handlers are a deliberate follow-up. Also why scriptText can be null. - Test coverage (Rames): multi-tween-per-element and shared-selector cross-element animationIds cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): CodeQL js/bad-tag-filter — match </script[^>]*> close tags `</script\s*>` still tripped CodeQL on attribute-junk closes like `</script foo>` (HTML5 ignores junk before `>`). Widen the close-tag match to `</script[^>]*>` in the GSAP-script extraction and the test regexes. 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
5f6ced116d
commit
7593aac5ef
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||||
|
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||||
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
|
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
|
||||||
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
||||||
|
|
||||||
@@ -37,8 +38,60 @@ function ownText(el: Element): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
|
||||||
|
// only on the script text, so memoize the {tween id, selector} pairs by script.
|
||||||
|
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
|
||||||
|
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
|
||||||
|
// repeated getElements() rebuilds) and stays bounded.
|
||||||
|
let gsapLocatedCacheKey: string | null = null;
|
||||||
|
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
|
||||||
|
|
||||||
|
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
|
||||||
|
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
|
||||||
|
const parsed = parseGsapScriptAcornForWrite(script);
|
||||||
|
gsapLocatedCacheVal = parsed
|
||||||
|
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
|
||||||
|
: [];
|
||||||
|
gsapLocatedCacheKey = script;
|
||||||
|
return gsapLocatedCacheVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
|
||||||
|
* come from the acorn parser's stable `targetSelector-method-position` scheme —
|
||||||
|
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
|
||||||
|
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
|
||||||
|
* malformed selector or unparseable script yields no entries (animationIds: []).
|
||||||
|
*/
|
||||||
|
function buildAnimationIdMap(document: Document): Map<string, string[]> {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
const script = extractGsapScript(document);
|
||||||
|
if (!script) return map;
|
||||||
|
for (const { id, selector } of parseLocatedCached(script)) {
|
||||||
|
if (!selector) continue;
|
||||||
|
let matches: Element[] = [];
|
||||||
|
try {
|
||||||
|
matches = Array.from(document.querySelectorAll(selector));
|
||||||
|
} catch {
|
||||||
|
continue; // selector not valid for querySelectorAll — skip
|
||||||
|
}
|
||||||
|
for (const el of matches) {
|
||||||
|
const hfId = el.getAttribute("data-hf-id");
|
||||||
|
if (!hfId) continue;
|
||||||
|
const list = map.get(hfId);
|
||||||
|
if (list) list.push(id);
|
||||||
|
else map.set(hfId, [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null {
|
function buildElement(
|
||||||
|
el: Element,
|
||||||
|
scopePrefix: string,
|
||||||
|
animationIdsByHfId: Map<string, string[]>,
|
||||||
|
): HyperFramesElement | null {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (EXCLUDED_TAGS.has(tag)) return null;
|
if (EXCLUDED_TAGS.has(tag)) return null;
|
||||||
|
|
||||||
@@ -82,7 +135,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
|
|||||||
|
|
||||||
const children: HyperFramesElement[] = [];
|
const children: HyperFramesElement[] = [];
|
||||||
for (const child of Array.from(el.children)) {
|
for (const child of Array.from(el.children)) {
|
||||||
const built = buildElement(child, childPrefix);
|
const built = buildElement(child, childPrefix, animationIdsByHfId);
|
||||||
if (built) children.push(built);
|
if (built) children.push(built);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +151,18 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
|
|||||||
start,
|
start,
|
||||||
duration,
|
duration,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
animationIds: [],
|
animationIds: animationIdsByHfId.get(id) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
function extractGsapScript(doc: Document): string | null {
|
function extractGsapScript(doc: Document): string | null {
|
||||||
// GSAP script is the first <script> tag whose text references gsap
|
// GSAP script is the first <script> tag whose text references gsap. Marker
|
||||||
|
// set must match studio sdkShadow.ts isGsapScriptBody so both pick the same
|
||||||
|
// script from a given composition.
|
||||||
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||||
const text = script.textContent ?? "";
|
const text = script.textContent ?? "";
|
||||||
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
|
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,9 +206,10 @@ function extractDuration(doc: Document): number | null {
|
|||||||
export function buildRoots(document: Document): HyperFramesElement[] {
|
export function buildRoots(document: Document): HyperFramesElement[] {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const roots: HyperFramesElement[] = [];
|
const roots: HyperFramesElement[] = [];
|
||||||
|
const animationIdsByHfId = buildAnimationIdMap(document);
|
||||||
if (body) {
|
if (body) {
|
||||||
for (const child of Array.from(body.children)) {
|
for (const child of Array.from(body.children)) {
|
||||||
const built = buildElement(child, "");
|
const built = buildElement(child, "", animationIdsByHfId);
|
||||||
if (built) roots.push(built);
|
if (built) roots.push(built);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const BASE_HTML = `
|
|||||||
class TestPreviewAdapter implements PreviewAdapter {
|
class TestPreviewAdapter implements PreviewAdapter {
|
||||||
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
||||||
|
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -386,6 +387,7 @@ describe("setSelection", () => {
|
|||||||
expect(patches).toHaveLength(0);
|
expect(patches).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
it("setSelection with same ids does not fire selectionchange again", async () => {
|
it("setSelection with same ids does not fire selectionchange again", async () => {
|
||||||
const comp = await openComposition(BASE_HTML);
|
const comp = await openComposition(BASE_HTML);
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
@@ -419,3 +421,65 @@ describe("setSelection", () => {
|
|||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("animationIds population", () => {
|
||||||
|
const GSAP_HTML = `
|
||||||
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||||
|
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
||||||
|
<div data-hf-id="hf-plain">plain</div>
|
||||||
|
<script>var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||||
|
window.__timelines["t"] = tl;</script>
|
||||||
|
</div>`.trim();
|
||||||
|
|
||||||
|
it("attaches the parser's stable tween id to the targeted element", async () => {
|
||||||
|
const comp = await openComposition(GSAP_HTML);
|
||||||
|
const box = comp.getElement("hf-box");
|
||||||
|
expect(box?.animationIds.length).toBe(1);
|
||||||
|
// Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position.
|
||||||
|
expect(box?.animationIds[0]).toContain("hf-box");
|
||||||
|
expect(box?.animationIds[0]).toContain("-to-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves untargeted elements with an empty animationIds", async () => {
|
||||||
|
const comp = await openComposition(GSAP_HTML);
|
||||||
|
expect(comp.getElement("hf-plain")?.animationIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the populated id is dispatchable as a removeGsapTween target", async () => {
|
||||||
|
const comp = await openComposition(GSAP_HTML);
|
||||||
|
const id = comp.getElement("hf-box")?.animationIds[0];
|
||||||
|
expect(id).toBeDefined();
|
||||||
|
if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches multiple distinct tween ids when one element has several tweens", async () => {
|
||||||
|
const html = `
|
||||||
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||||
|
<div data-hf-id="hf-box" style="opacity: 0">box</div>
|
||||||
|
<script>var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
||||||
|
tl.from("[data-hf-id=\\"hf-box\\"]", { x: -100, duration: 0.5 }, 1);
|
||||||
|
window.__timelines["t"] = tl;</script>
|
||||||
|
</div>`.trim();
|
||||||
|
const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? [];
|
||||||
|
expect(ids.length).toBe(2);
|
||||||
|
expect(new Set(ids).size).toBe(2); // distinct
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fans a shared-selector tween out to every matched element", async () => {
|
||||||
|
const html = `
|
||||||
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px">
|
||||||
|
<div data-hf-id="hf-a" class="fade">a</div>
|
||||||
|
<div data-hf-id="hf-b" class="fade">b</div>
|
||||||
|
<script>var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to(".fade", { opacity: 1, duration: 0.5 }, 0);
|
||||||
|
window.__timelines["t"] = tl;</script>
|
||||||
|
</div>`.trim();
|
||||||
|
const comp = await openComposition(html);
|
||||||
|
const a = comp.getElement("hf-a")?.animationIds ?? [];
|
||||||
|
const b = comp.getElement("hf-b")?.animationIds ?? [];
|
||||||
|
expect(a.length).toBe(1);
|
||||||
|
expect(b).toEqual(a); // same tween id on both matched elements
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
|
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
||||||
|
|
||||||
export interface MutationResult {
|
export interface MutationResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -18,6 +19,8 @@ export interface CommitMutationOptions {
|
|||||||
softReload?: boolean;
|
softReload?: boolean;
|
||||||
skipReload?: boolean;
|
skipReload?: boolean;
|
||||||
beforeReload?: () => void;
|
beforeReload?: () => void;
|
||||||
|
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
||||||
|
shadowGsapOp?: ShadowGsapOp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommitMutation = (
|
export type CommitMutation = (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { roundTo3 } from "../utils/rounding";
|
import { roundTo3 } from "../utils/rounding";
|
||||||
import { runShadowGsapTween } from "../utils/sdkShadow";
|
import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
|
||||||
import {
|
import {
|
||||||
assignGsapTargetAutoIdIfNeeded,
|
assignGsapTargetAutoIdIfNeeded,
|
||||||
ensureElementAddressable,
|
ensureElementAddressable,
|
||||||
@@ -33,27 +33,34 @@ export function useGsapAnimationOps({
|
|||||||
animationId: string,
|
animationId: string,
|
||||||
updates: { duration?: number; ease?: string; position?: number },
|
updates: { duration?: number; ease?: string; position?: number },
|
||||||
) => {
|
) => {
|
||||||
|
// Shadow op (server animationId shares the SDK id-space): existence via
|
||||||
|
// runShadowGsapTween (live session) + value fidelity via the chokepoint.
|
||||||
|
const shadowGsapOp: ShadowGsapOp = {
|
||||||
|
kind: "set",
|
||||||
|
animationId,
|
||||||
|
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
||||||
|
};
|
||||||
commitMutationSafely(
|
commitMutationSafely(
|
||||||
selection,
|
selection,
|
||||||
{ type: "update-meta", animationId, updates },
|
{ type: "update-meta", animationId, updates },
|
||||||
{
|
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
|
||||||
label: "Edit GSAP animation",
|
|
||||||
coalesceKey: `gsap:${animationId}:meta`,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
||||||
},
|
},
|
||||||
[commitMutationSafely],
|
[commitMutationSafely, sdkSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteGsapAnimation = useCallback(
|
const deleteGsapAnimation = useCallback(
|
||||||
(selection: DomEditSelection, animationId: string) => {
|
(selection: DomEditSelection, animationId: string) => {
|
||||||
|
const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
|
||||||
commitMutationSafely(
|
commitMutationSafely(
|
||||||
selection,
|
selection,
|
||||||
{ type: "delete", animationId, stripStudioEdits: true },
|
{ type: "delete", animationId, stripStudioEdits: true },
|
||||||
{ label: "Delete GSAP animation" },
|
{ label: "Delete GSAP animation", shadowGsapOp },
|
||||||
);
|
);
|
||||||
|
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
||||||
},
|
},
|
||||||
[commitMutationSafely],
|
[commitMutationSafely, sdkSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllForSelector = useCallback(
|
const deleteAllForSelector = useCallback(
|
||||||
@@ -103,6 +110,26 @@ export function useGsapAnimationOps({
|
|||||||
fromTo: { x: 0, y: 0, opacity: 1 },
|
fromTo: { x: 0, y: 0, opacity: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shadow op (server stays authoritative). "set" has no SDK method, so it
|
||||||
|
// is not shadowed; otherwise: existence via runShadowGsapTween (live) +
|
||||||
|
// value fidelity via the chokepoint (shadowGsapOp in options).
|
||||||
|
const shadowGsapOp: ShadowGsapOp | undefined =
|
||||||
|
selection.hfId && method !== "set"
|
||||||
|
? {
|
||||||
|
kind: "add",
|
||||||
|
target: selection.hfId,
|
||||||
|
tween: {
|
||||||
|
method,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
ease: "power2.out",
|
||||||
|
...(method === "fromTo"
|
||||||
|
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
||||||
|
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await commitMutation(
|
await commitMutation(
|
||||||
selection,
|
selection,
|
||||||
{
|
{
|
||||||
@@ -115,25 +142,10 @@ export function useGsapAnimationOps({
|
|||||||
properties: toDefaults[method] ?? { opacity: 1 },
|
properties: toDefaults[method] ?? { opacity: 1 },
|
||||||
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
|
||||||
},
|
},
|
||||||
{ label: `Add GSAP ${method} animation` },
|
{ label: `Add GSAP ${method} animation`, shadowGsapOp },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shadow: dispatch the equivalent addGsapTween to the SDK (server stays
|
if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
|
||||||
// authoritative). "set" has no SDK method, so it is not shadowed.
|
|
||||||
// ponytail: only add is shadowed — delete/update key on the server's
|
|
||||||
// animationId, which doesn't resolve in the SDK's independent id-space.
|
|
||||||
if (sdkSession && selection.hfId && method !== "set") {
|
|
||||||
const tween: GsapTweenSpec = {
|
|
||||||
method,
|
|
||||||
position,
|
|
||||||
duration,
|
|
||||||
ease: "power2.out",
|
|
||||||
...(method === "fromTo"
|
|
||||||
? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
|
|
||||||
: { properties: toDefaults[method] ?? { opacity: 1 } }),
|
|
||||||
};
|
|
||||||
runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|||||||
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||||
|
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
|
||||||
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
||||||
import {
|
import {
|
||||||
GsapMutationHttpError,
|
GsapMutationHttpError,
|
||||||
@@ -67,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|||||||
}
|
}
|
||||||
if (result.changed === false) return;
|
if (result.changed === false) return;
|
||||||
domEditSaveTimestampRef.current = Date.now();
|
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.
|
||||||
|
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
||||||
|
const fidelityArgs = resolveGsapFidelityArgs(
|
||||||
|
sdkSession,
|
||||||
|
options.shadowGsapOp,
|
||||||
|
result.before,
|
||||||
|
result.scriptText,
|
||||||
|
);
|
||||||
|
if (fidelityArgs) {
|
||||||
|
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
||||||
|
}
|
||||||
if (result.before != null && result.after != null) {
|
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 } } });
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
||||||
}
|
}
|
||||||
@@ -80,7 +96,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|||||||
reloadPreview();
|
reloadPreview();
|
||||||
}
|
}
|
||||||
onCacheInvalidate();
|
onCacheInvalidate();
|
||||||
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]);
|
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
|
||||||
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
||||||
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
||||||
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
||||||
|
import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||||
type CommitMutationOptions = {
|
|
||||||
label: string;
|
|
||||||
coalesceKey?: string;
|
|
||||||
softReload?: boolean;
|
|
||||||
skipReload?: boolean;
|
|
||||||
beforeReload?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommitMutation = (
|
|
||||||
selection: DomEditSelection,
|
|
||||||
mutation: Record<string, unknown>,
|
|
||||||
options: CommitMutationOptions,
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
type TrackGsapSaveFailure = (
|
type TrackGsapSaveFailure = (
|
||||||
error: unknown,
|
error: unknown,
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import {
|
|||||||
runShadowDelete,
|
runShadowDelete,
|
||||||
runShadowTiming,
|
runShadowTiming,
|
||||||
runShadowGsapTween,
|
runShadowGsapTween,
|
||||||
|
runShadowGsapFidelity,
|
||||||
|
gsapFidelityMismatches,
|
||||||
|
resolveGsapFidelityArgs,
|
||||||
SdkShadowMismatch,
|
SdkShadowMismatch,
|
||||||
} from "./sdkShadow";
|
} from "./sdkShadow";
|
||||||
|
import type { ShadowGsapOp } from "./sdkShadow";
|
||||||
import type { PatchOperation } from "./sourcePatcher";
|
import type { PatchOperation } from "./sourcePatcher";
|
||||||
import { openComposition } from "@hyperframes/sdk";
|
import { openComposition } from "@hyperframes/sdk";
|
||||||
|
|
||||||
@@ -219,13 +223,24 @@ describe("runShadowTiming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("runShadowGsapTween", () => {
|
describe("runShadowGsapTween", () => {
|
||||||
it("dispatches add against a real timeline and reports success", async () => {
|
it("add reports success and the new tween lands on the target's animationIds", async () => {
|
||||||
const session = await openComposition(GSAP_HTML);
|
const session = await openComposition(GSAP_HTML);
|
||||||
|
const before = session.getElement("hf-box")?.animationIds.length ?? 0;
|
||||||
runShadowGsapTween(session, {
|
runShadowGsapTween(session, {
|
||||||
kind: "add",
|
kind: "add",
|
||||||
target: "hf-box",
|
target: "hf-box",
|
||||||
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||||
});
|
});
|
||||||
|
expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
|
||||||
|
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove drops the tween from animationIds and reports parity", async () => {
|
||||||
|
const session = await openComposition(GSAP_HTML);
|
||||||
|
const animationId = session.getElement("hf-box")?.animationIds[0];
|
||||||
|
expect(animationId).toBeDefined();
|
||||||
|
runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
|
||||||
|
expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
|
||||||
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,3 +259,118 @@ describe("runShadowGsapTween", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
|
||||||
|
describe("gsapFidelityMismatches", () => {
|
||||||
|
it("returns no mismatches for identical scripts", () => {
|
||||||
|
expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a per-field value drift (duration)", () => {
|
||||||
|
const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
|
||||||
|
const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
|
||||||
|
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a tween present in one script but not the other", () => {
|
||||||
|
const empty = `var tl = gsap.timeline({ paused: true });
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
|
||||||
|
expect(mismatches.some((m) => m.property === "tween")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag property key-order differences (canonical compare)", () => {
|
||||||
|
const ab = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
const ba = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag number-vs-string-equivalent property values", () => {
|
||||||
|
const numeric = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
const stringy = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
|
||||||
|
window.__timelines["t"] = tl;`;
|
||||||
|
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runShadowGsapFidelity", () => {
|
||||||
|
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||||
|
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||||
|
<script>var tl = gsap.timeline({ paused: true });
|
||||||
|
window.__timelines["t"] = tl;</script>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
it("reports zero mismatches when the SDK output matches the server script", async () => {
|
||||||
|
// Produce the "server" script by applying the same op via the SDK, so a
|
||||||
|
// faithful SDK writer must reproduce it exactly.
|
||||||
|
const ref = await openComposition(BEFORE_HTML);
|
||||||
|
const op = {
|
||||||
|
kind: "add",
|
||||||
|
target: "hf-box",
|
||||||
|
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||||
|
} as const;
|
||||||
|
ref.addGsapTween(op.target, op.tween);
|
||||||
|
const serverScript =
|
||||||
|
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
|
||||||
|
|
||||||
|
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
||||||
|
expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports mismatches when the server script diverges", async () => {
|
||||||
|
const op = {
|
||||||
|
kind: "add",
|
||||||
|
target: "hf-box",
|
||||||
|
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||||
|
} as const;
|
||||||
|
const ref = await openComposition(BEFORE_HTML);
|
||||||
|
ref.addGsapTween(op.target, op.tween);
|
||||||
|
const serverScript = (
|
||||||
|
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
|
||||||
|
).replace("100", "999");
|
||||||
|
|
||||||
|
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
||||||
|
const ev = lastShadow();
|
||||||
|
expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
|
||||||
|
expect(ev?.mismatchCount as number).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
|
||||||
|
const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
|
||||||
|
const session = {} as object;
|
||||||
|
|
||||||
|
it("returns narrowed args when session, op, before, and serverScript are all present", () => {
|
||||||
|
expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
|
||||||
|
before: "<html>before</html>",
|
||||||
|
op,
|
||||||
|
serverScript: "tl.to(...)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no session (shadow not wired)", () => {
|
||||||
|
expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
|
||||||
|
expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when serverScript is null (composition has no GSAP script)", () => {
|
||||||
|
expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when before is null", () => {
|
||||||
|
expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -367,12 +367,15 @@ export type ShadowGsapOp =
|
|||||||
| { kind: "remove"; animationId: string };
|
| { kind: "remove"; animationId: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the
|
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
|
||||||
* tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub
|
* shares the SDK's id-space (both derive `targetSelector-method-position` from
|
||||||
* (always [] — see sdk document.ts). So the signal here is can() addressing /
|
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
|
||||||
* validity + dispatch-didn't-throw, plus (for add) that the SDK returned a
|
*
|
||||||
* non-empty tween id. Full fidelity needs serialize()-script round-trip diffing,
|
* Parity via the now-populated ElementSnapshot.animationIds:
|
||||||
* out of scope for shadow. // ponytail: upgrade when animationIds is populated.
|
* add → the returned tween id is present on the target element
|
||||||
|
* remove → the id is gone from every element
|
||||||
|
* set → existence only (the SDK exposes no per-tween property reader; value
|
||||||
|
* fidelity would need serialize()-script round-trip diffing).
|
||||||
*/
|
*/
|
||||||
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
||||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||||
@@ -382,23 +385,50 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp):
|
|||||||
: gsapOp.kind === "set"
|
: gsapOp.kind === "set"
|
||||||
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
||||||
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
runShadowEditOp(session, op, "gsap", () => {
|
runShadowEditOp(session, op, "gsap", () => {
|
||||||
let newId: string | undefined;
|
let newId: string | undefined;
|
||||||
session.batch(() => {
|
session.batch(() => {
|
||||||
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
||||||
else session.dispatch(op);
|
else session.dispatch(op);
|
||||||
});
|
});
|
||||||
if (gsapOp.kind === "add" && !newId) {
|
if (gsapOp.kind === "add") {
|
||||||
return [
|
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
|
||||||
{
|
if (!newId || !onTarget.includes(newId)) {
|
||||||
kind: "value_mismatch",
|
return [
|
||||||
hfId: gsapOp.target,
|
{
|
||||||
property: "tweenId",
|
kind: "value_mismatch",
|
||||||
expected: "non-empty",
|
hfId: gsapOp.target,
|
||||||
actual: null,
|
property: "animationIds",
|
||||||
},
|
expected: newId ?? "non-empty",
|
||||||
];
|
actual: onTarget.join(",") || null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else if (gsapOp.kind === "remove") {
|
||||||
|
const stillPresent = session
|
||||||
|
.getElements()
|
||||||
|
.some((el) => el.animationIds.includes(gsapOp.animationId));
|
||||||
|
if (stillPresent) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "value_mismatch",
|
||||||
|
hfId: gsapOp.animationId,
|
||||||
|
property: "animationIds",
|
||||||
|
expected: "removed",
|
||||||
|
actual: "present",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GSAP value-fidelity diff lives in its own module to keep this file under the
|
||||||
|
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
|
||||||
|
export {
|
||||||
|
gsapFidelityMismatches,
|
||||||
|
resolveGsapFidelityArgs,
|
||||||
|
runShadowGsapFidelity,
|
||||||
|
} from "./sdkShadowGsapFidelity";
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* GSAP value-fidelity shadow (serialize round-trip diff). Split out of
|
||||||
|
* sdkShadow.ts to keep that file under the 600-line studio cap.
|
||||||
|
*
|
||||||
|
* Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
|
||||||
|
* that its VALUES (duration / ease / position / properties) match the server.
|
||||||
|
* The SDK exposes no per-tween property reader, so we compare the two writers'
|
||||||
|
* output: apply the same op to a fresh SDK doc opened from the server's pre-op
|
||||||
|
* file, then structurally diff the SDK's GSAP script against the server's
|
||||||
|
* resulting script. Both are re-parsed, so formatting/whitespace differences
|
||||||
|
* never produce false positives — only real value drift does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openComposition } from "@hyperframes/sdk";
|
||||||
|
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
||||||
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||||
|
import { trackStudioEvent } from "./studioTelemetry";
|
||||||
|
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
||||||
|
|
||||||
|
// Marker set must match document.ts extractGsapScript so both pick the same
|
||||||
|
// <script> from any given composition.
|
||||||
|
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[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
||||||
|
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animById(script: string): Map<string, GsapAnimation> {
|
||||||
|
const map = new Map<string, GsapAnimation>();
|
||||||
|
const parsed = parseGsapScriptAcorn(script);
|
||||||
|
for (const anim of parsed.animations) map.set(anim.id, anim);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
|
||||||
|
// writers, so the same tween can serialize with different property key order or
|
||||||
|
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
||||||
|
// strings — so only real value drift registers, not formatting differences.
|
||||||
|
|
||||||
|
function numericEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
const na = typeof a === "string" ? Number(a) : a;
|
||||||
|
const nb = typeof b === "string" ? Number(b) : b;
|
||||||
|
return (
|
||||||
|
typeof na === "number" &&
|
||||||
|
typeof nb === "number" &&
|
||||||
|
!Number.isNaN(na) &&
|
||||||
|
!Number.isNaN(nb) &&
|
||||||
|
na === nb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
||||||
|
if (!obj) return "{}";
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(obj).sort()) {
|
||||||
|
const v = obj[key];
|
||||||
|
// normalize "0.5" → 0.5 so a number/string writer difference isn't drift
|
||||||
|
out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
||||||
|
}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structurally diff two GSAP scripts by tween id. Reports a tween present in
|
||||||
|
* one but not the other, and per-field value drift (method, position, duration,
|
||||||
|
* ease, properties, fromProperties). Comparison is canonical (see above) so
|
||||||
|
* writer formatting differences do not produce false mismatches.
|
||||||
|
*/
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function gsapFidelityMismatches(
|
||||||
|
sdkScript: string,
|
||||||
|
serverScript: string,
|
||||||
|
): SdkShadowMismatch[] {
|
||||||
|
const sdk = animById(sdkScript);
|
||||||
|
const server = animById(serverScript);
|
||||||
|
const mismatches: SdkShadowMismatch[] = [];
|
||||||
|
const ids = new Set([...sdk.keys(), ...server.keys()]);
|
||||||
|
for (const id of ids) {
|
||||||
|
const a = sdk.get(id);
|
||||||
|
const b = server.get(id);
|
||||||
|
if (!a || !b) {
|
||||||
|
mismatches.push({
|
||||||
|
kind: "value_mismatch",
|
||||||
|
hfId: id,
|
||||||
|
property: "tween",
|
||||||
|
expected: b ? "present" : "absent",
|
||||||
|
actual: a ? "present" : "absent",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// [property, sdk-value, server-value, equal?]
|
||||||
|
const fields: Array<[string, unknown, unknown, boolean]> = [
|
||||||
|
["method", a.method, b.method, a.method === b.method],
|
||||||
|
["position", a.position, b.position, numericEqual(a.position, b.position)],
|
||||||
|
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
|
||||||
|
["ease", a.ease, b.ease, a.ease === b.ease],
|
||||||
|
[
|
||||||
|
"properties",
|
||||||
|
a.properties,
|
||||||
|
b.properties,
|
||||||
|
canonicalProps(a.properties) === canonicalProps(b.properties),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"fromProperties",
|
||||||
|
a.fromProperties,
|
||||||
|
b.fromProperties,
|
||||||
|
canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [property, av, bv, equal] of fields) {
|
||||||
|
if (!equal) {
|
||||||
|
mismatches.push({
|
||||||
|
kind: "value_mismatch",
|
||||||
|
hfId: id,
|
||||||
|
property,
|
||||||
|
expected: bv == null ? null : JSON.stringify(bv),
|
||||||
|
actual: av == null ? null : JSON.stringify(av),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GsapFidelityArgs {
|
||||||
|
before: string;
|
||||||
|
op: ShadowGsapOp;
|
||||||
|
serverScript: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
|
||||||
|
* args only when there is a live session, a typed shadow op, and both the
|
||||||
|
* pre-op file and the server's resulting script to diff against (scriptText is
|
||||||
|
* null when the composition has no GSAP script). Returns null otherwise. Pure +
|
||||||
|
* narrowing so the wiring decision is unit-testable without rendering the hook
|
||||||
|
* and the caller needs no non-null assertions.
|
||||||
|
*/
|
||||||
|
export function resolveGsapFidelityArgs(
|
||||||
|
sdkSession: unknown,
|
||||||
|
shadowGsapOp: ShadowGsapOp | undefined,
|
||||||
|
before: string | null | undefined,
|
||||||
|
serverScript: string | null | undefined,
|
||||||
|
): GsapFidelityArgs | null {
|
||||||
|
if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
|
||||||
|
return { before, op: shadowGsapOp, serverScript };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
|
||||||
|
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
|
||||||
|
* against the server's resulting script. Emits sdk_shadow_dispatch op:
|
||||||
|
* "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
|
||||||
|
*/
|
||||||
|
export async function runShadowGsapFidelity(
|
||||||
|
beforeHtml: string,
|
||||||
|
gsapOp: ShadowGsapOp,
|
||||||
|
serverScript: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||||
|
// No server script to diff against → skip the (costly) openComposition.
|
||||||
|
if (!serverScript || !beforeHtml) return;
|
||||||
|
try {
|
||||||
|
const session = await openComposition(beforeHtml);
|
||||||
|
session.batch(() => {
|
||||||
|
if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
|
||||||
|
else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
|
||||||
|
else session.removeGsapTween(gsapOp.animationId);
|
||||||
|
});
|
||||||
|
const sdkScript = extractGsapScript(session.serialize());
|
||||||
|
if (sdkScript == null) {
|
||||||
|
trackStudioEvent("sdk_shadow_dispatch", {
|
||||||
|
op: "gsap_fidelity",
|
||||||
|
dispatched: false,
|
||||||
|
reason: "no_sdk_script",
|
||||||
|
mismatchCount: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
|
||||||
|
trackStudioEvent("sdk_shadow_dispatch", {
|
||||||
|
op: "gsap_fidelity",
|
||||||
|
dispatched: true,
|
||||||
|
mismatchCount: mismatches.length,
|
||||||
|
mismatches: JSON.stringify(mismatches),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
trackStudioEvent("sdk_shadow_dispatch", {
|
||||||
|
op: "gsap_fidelity",
|
||||||
|
dispatched: false,
|
||||||
|
reason: "fidelity_error",
|
||||||
|
error: String(err),
|
||||||
|
mismatchCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user