Merge pull request #2189 from heygen-com/fix/sdk-template-gsap-resolver-parity

fix(sdk): include template GSAP scripts in resolver parity
This commit is contained in:
Vance Ingalls
2026-07-10 18:22:34 -07:00
committed by GitHub
8 changed files with 222 additions and 64 deletions
+29 -26
View File
@@ -14,6 +14,7 @@ import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acor
import {
findRoot,
getElementStyles,
getGsapScripts,
getOwnText,
isNewHostBoundary,
querySelectorAllDeep,
@@ -67,27 +68,38 @@ function parseLocatedCached(script: string): Array<{ id: string; selector: strin
*/
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 = querySelectorAllDeep(document, 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]);
for (const script of getGsapScripts(document)) {
for (const { id, selector } of parseLocatedCached(script)) {
appendAnimationIdsForSelector(map, document, id, selector);
}
}
return map;
}
function appendAnimationIdsForSelector(
map: Map<string, string[]>,
document: Document,
animationId: string,
selector: string,
): void {
if (!selector) return;
let matches: Element[];
try {
matches = querySelectorAllDeep(document, selector);
} catch {
return; // 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(animationId);
else map.set(hfId, [animationId]);
}
}
/**
* Every GSAP tween id `parseLocatedCached` finds in the script, with no DOM
* matching at all — the same id space the server-side script ops
@@ -198,16 +210,7 @@ function buildElement(
// fallow-ignore-next-line complexity
function extractGsapScript(doc: Document): string | null {
// 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("__timelines") || text.includes("ScrollTrigger")) {
return text;
}
}
return null;
return getGsapScripts(doc)[0] ?? null;
}
function extractStyles(doc: Document): string | null {
+25 -5
View File
@@ -6,7 +6,11 @@
*/
import { parseHTML } from "linkedom";
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/core/hf-ids";
import {
ensureHfIds,
isCompositionTemplate,
walkCompositionDescendants,
} from "@hyperframes/core/hf-ids";
export interface ParsedDocument {
document: Document;
@@ -372,12 +376,28 @@ export function setStyleSheet(document: Document, css: string): void {
// ─── GSAP script helpers ──────────────────────────────────────────────────────
function findScriptElementsDeep(document: Document): Element[] {
const scripts: Element[] = [];
walkCompositionDescendants(document, (child) => {
if (child.tagName.toLowerCase() === "script") scripts.push(child);
});
return scripts;
}
function isGsapScriptText(text: string): boolean {
return text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger");
}
export function getGsapScripts(document: Document): string[] {
return findScriptElementsDeep(document)
.map((script) => script.textContent ?? "")
.filter(isGsapScriptText);
}
function findGsapScriptElement(document: Document): Element | null {
const scripts = document.querySelectorAll("script");
for (const script of Array.from(scripts)) {
for (const script of findScriptElementsDeep(document)) {
const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("ScrollTrigger"))
return script as unknown as Element;
if (isGsapScriptText(text)) return script;
}
return null;
}
+16
View File
@@ -78,6 +78,22 @@ describe("template-based sub-comp compositions", () => {
);
expect(comp.getElement("hf-dup")?.text).toBe("tpl");
});
it("models GSAP animations declared inside a composition template", async () => {
const comp = await openComposition(`
<template data-composition-id="document-card">
<div data-hf-id="hf-line" class="line">line</div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-line\\"]", { x: 100, duration: 1 }, 0);
</script>
</template>
`);
const animationIds = comp.getElement("hf-line")?.animationIds ?? [];
expect(animationIds).toHaveLength(1);
expect(comp.getAllAnimationIds()).toEqual(new Set(animationIds));
});
});
// The authored sub-comp form `hyperframes add` scaffolds: the composition id is
+20
View File
@@ -45,6 +45,19 @@ window.__timelines = { t: tl };</script>
</div>
`.trim();
const GSAP_TEMPLATE_LABEL_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<template data-composition-id="label-sub-comp">
<div data-hf-id="hf-box" data-start="0" data-duration="5"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.addLabel("template-label", 1.5);
window.__timelines = { t: tl };
</script>
</template>
</div>
`.trim();
// ─── getElementTimings — duration-authored clips ──────────────────────────────
describe("getElementTimings — duration-authored clips", () => {
@@ -111,6 +124,13 @@ describe("getElementTimings — GSAP labels", () => {
const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(after).toContain("intro");
});
it("reads labels from GSAP scripts inside composition templates", async () => {
const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML);
const labels = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(labels).toContain("template-label");
});
});
// ─── getElementTimings — relative data-start references ──────────────────────
+23 -16
View File
@@ -35,7 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScript, resolveScoped, declarationElement } from "./engine/model.js";
import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
@@ -314,26 +314,30 @@ class CompositionImpl implements Composition {
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
/**
* Cache of parsed GSAP labels keyed by EXACT script text. extractGsapLabels does
* a full acorn parse; caching avoids re-parsing on repeated getElementTimings reads
* when the script is unchanged. The content (not reference) key means any script
* edit changes the text and invalidates the cache, so renumbered tweens never yield
* stale label positions.
* Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
* extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
* getElementTimings reads when the scripts are unchanged.
*/
private _gsapLabelCache: { script: string; labels: ReturnType<typeof extractGsapLabels> } | null =
null;
private _gsapLabelCache: {
scripts: string[];
labels: ReturnType<typeof extractGsapLabels>;
} | null = null;
// fallow-ignore-next-line complexity
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
const script = getGsapScript(this.parsed.document);
const scripts = getGsapScripts(this.parsed.document);
// Extract all addLabel("name", position) calls from the GSAP script (see cache note above).
// Extract all addLabel("name", position) calls from every GSAP script.
let allLabels: ReturnType<typeof extractGsapLabels>;
if (script && this._gsapLabelCache?.script === script) {
allLabels = this._gsapLabelCache.labels;
const cachedScripts = this._gsapLabelCache?.scripts;
const cacheMatches =
cachedScripts?.length === scripts.length &&
cachedScripts.every((script, index) => script === scripts[index]);
if (cacheMatches) {
allLabels = this._gsapLabelCache?.labels ?? [];
} else {
allLabels = script ? extractGsapLabels(script) : [];
this._gsapLabelCache = script ? { script, labels: allLabels } : null;
allLabels = scripts.flatMap((script) => extractGsapLabels(script));
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
}
// Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —
@@ -555,8 +559,11 @@ class CompositionImpl implements Composition {
}
getAllAnimationIds(): Set<string> {
const script = getGsapScript(this.parsed.document);
return script ? parsedAnimationIds(script) : new Set();
const ids = new Set<string>();
for (const script of getGsapScripts(this.parsed.document)) {
for (const id of parsedAnimationIds(script)) ids.add(id);
}
return ids;
}
// ── Selection API ────────────────────────────────────────────────────────────