mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user