mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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 { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
|
||||
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
||||
|
||||
@@ -37,8 +38,60 @@ function ownText(el: Element): string | 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
|
||||
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();
|
||||
if (EXCLUDED_TAGS.has(tag)) return null;
|
||||
|
||||
@@ -82,7 +135,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
|
||||
|
||||
const children: HyperFramesElement[] = [];
|
||||
for (const child of Array.from(el.children)) {
|
||||
const built = buildElement(child, childPrefix);
|
||||
const built = buildElement(child, childPrefix, animationIdsByHfId);
|
||||
if (built) children.push(built);
|
||||
}
|
||||
|
||||
@@ -98,16 +151,18 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu
|
||||
start,
|
||||
duration,
|
||||
trackIndex,
|
||||
animationIds: [],
|
||||
animationIds: animationIdsByHfId.get(id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
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"))) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("ScrollTrigger")) {
|
||||
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -151,9 +206,10 @@ function extractDuration(doc: Document): number | null {
|
||||
export function buildRoots(document: Document): HyperFramesElement[] {
|
||||
const body = document.body;
|
||||
const roots: HyperFramesElement[] = [];
|
||||
const animationIdsByHfId = buildAnimationIdMap(document);
|
||||
if (body) {
|
||||
for (const child of Array.from(body.children)) {
|
||||
const built = buildElement(child, "");
|
||||
const built = buildElement(child, "", animationIdsByHfId);
|
||||
if (built) roots.push(built);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const BASE_HTML = `
|
||||
class TestPreviewAdapter implements PreviewAdapter {
|
||||
private selectionHandlers: Array<(ids: string[]) => void> = [];
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
return null;
|
||||
}
|
||||
@@ -386,6 +387,7 @@ describe("setSelection", () => {
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("setSelection with same ids does not fire selectionchange again", async () => {
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const calls: string[][] = [];
|
||||
@@ -419,3 +421,65 @@ describe("setSelection", () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user