Merge pull request #965 from heygen-com/fix/sub-comp-timeline-t0

fix: activate nested child timelines on renderSeek (sub-comp at t=0)
This commit is contained in:
Miguel Ángel
2026-05-19 23:11:32 +02:00
committed by GitHub
18 changed files with 1828 additions and 10 deletions
@@ -496,4 +496,72 @@ window.__afterTimeline = window.__timelines.scene;
expect(fakeWindow.__afterTimeline).toBe("updated");
expect(errorSpy).not.toHaveBeenCalled();
});
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
const scoped = scopeCssToComposition(
`#intro { background: #111; }
#intro .title { font-size: 120px; color: #fff; }`,
"intro",
undefined,
"intro",
);
// #intro should become [data-hf-authored-id="intro"]
expect(scoped).toContain('[data-hf-authored-id="intro"]');
expect(scoped).toContain('[data-hf-authored-id="intro"] .title');
// Raw #intro selectors should be gone
expect(scoped).not.toMatch(/#intro\b/);
});
it('does not rewrite [id="intro"] attribute selectors', () => {
// The function only targets #intro hash selectors, not [id="intro"] attribute selectors
const result = scopeCssToComposition(
'[id="intro"] .title { color: red; }',
"intro",
undefined,
"intro",
);
expect(result).toContain('[id="intro"]');
});
it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
const { document } = parseHTML(`
<div data-composition-id="intro">
<div data-hf-authored-id="intro">
<div class="title">HELLO</div>
</div>
</div>
`);
const gsapTargets: string[][] = [];
const fakeWindow = {
document,
__timelines: {},
gsap: {
timeline: () => ({
fromTo(targets: Element[], _from: unknown, _to: unknown) {
gsapTargets.push(Array.from(targets).map((t) => t.textContent || ""));
return this;
},
}),
},
};
const wrapped = wrapScopedCompositionScript(
`
var tl = gsap.timeline({ paused: true });
tl.fromTo('#intro .title', { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines['intro'] = tl;
`,
"intro",
"[HyperFrames] composition script error:",
undefined,
"intro",
"intro",
);
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
// The scoped script should resolve '#intro .title' against the
// data-hf-authored-id="intro" element, finding the .title child.
expect(gsapTargets).toEqual([["HELLO"]]);
});
});
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { inlineSubCompositions } from "./inlineSubCompositions";
// Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked.
/**
* Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope.
* This is the pattern that breaks when the producer path strips the inner root.
*/
const SUB_COMP_HTML = `<template id="intro-template">
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title" style="opacity:0;">HELLO WORLD</div>
<style>
#intro { position:relative; width:1920px; height:1080px; background:#111; }
#intro .title { font-size:120px; color:#fff; }
</style>
<script>
(function() {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.fromTo('#intro .title', { opacity:0 }, { opacity:1, duration:0.5 }, 0.2);
window.__timelines['intro'] = tl;
})();
</script>
</div>
</template>`;
function makeHostDocument(compId: string) {
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="${compId}" data-composition-src="intro.html"
data-start="0" data-duration="4" data-track-index="0"></div>
</div>
</body></html>`);
return document;
}
describe("inlineSubCompositions #ID selector scoping divergence", () => {
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The producer path takes innerHTML when compId matches, stripping the
// wrapper <div id="intro" ...>. The host element should NOT contain a
// child with id="intro" — the id attribute is lost.
const innerRootById = host.querySelector("#intro");
expect(innerRootById).toBeNull();
// The host itself still has data-composition-id="intro" (from the
// original markup), but no element inside has id="intro".
expect(host.getAttribute("data-composition-id")).toBe("intro");
// CSS was scoped: #intro selectors should be rewritten to use
// data-hf-authored-id attribute selector so they still resolve.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).not.toContain("#intro");
});
it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
// so that the selector resolves against the flattened structure.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
});
it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The wrapped script should contain the authored root id normalization
// logic so that runtime querySelector('#intro .title') maps to the
// data-hf-authored-id attribute selector.
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain("__hfAuthoredRootId");
expect(wrappedScript).toContain('"intro"');
});
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
// Simulate the bundler's flattenInnerRoot: clone the element, add
// data-hf-authored-id, strip timing attrs (simplified here).
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
const authoredId = clone.getAttribute("id");
if (authoredId) {
clone.setAttribute("data-hf-authored-id", authoredId);
clone.removeAttribute("id");
}
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
// With flattenInnerRoot, the inner root is preserved as a child of the
// host via outerHTML. The data-hf-authored-id attribute is present.
const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
expect(authoredRoot).not.toBeNull();
// CSS is still rewritten to use the attribute selector.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
});
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The inner root's id="intro" is stripped (innerHTML), but the producer
// now propagates it as data-hf-authored-id on the host element so that
// rewritten #ID selectors ([data-hf-authored-id="intro"]) resolve.
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
// The original #intro element is still gone — innerHTML stripped it.
const introById = host.querySelector("#intro");
expect(introById).toBeNull();
expect(host.getAttribute("data-composition-id")).toBe("intro");
});
});
@@ -305,6 +305,12 @@ export function inlineSubCompositions(
hostEl.innerHTML = prepared.outerHTML || "";
} else {
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
// When the producer path strips the inner root (innerHTML), the
// authored id attribute is lost. Propagate it to the host so that
// rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
if (compId && authoredRootId) {
hostEl.setAttribute("data-hf-authored-id", authoredRootId);
}
}
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
+69
View File
@@ -444,6 +444,75 @@ describe("initSandboxRuntimeModular", () => {
expect(video.currentTime).toBe(0);
});
it("activates sub-composition timelines at data-start near 0 during renderSeek", () => {
// Regression: sub-compositions starting at or near t=0 had their GSAP
// sub-timelines ignored during render because renderSeek did not
// activate (unpause) nested child timelines before seeking the root.
// The children were added to the root while paused, and GSAP's
// totalTime() does not propagate to paused children.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "24");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hookHost = document.createElement("div");
hookHost.setAttribute("data-composition-id", "hook");
hookHost.setAttribute("data-start", "0.001");
hookHost.setAttribute("data-duration", "2");
hookHost.setAttribute("data-track-index", "0");
hookHost.classList.add("clip");
root.appendChild(hookHost);
const laterHost = document.createElement("div");
laterHost.setAttribute("data-composition-id", "tweet");
laterHost.setAttribute("data-start", "1.5");
laterHost.setAttribute("data-duration", "4.5");
laterHost.setAttribute("data-track-index", "1");
laterHost.classList.add("clip");
root.appendChild(laterHost);
const hookTimeline = createMockTimeline(2);
const tweetTimeline = createMockTimeline(4.5);
const rootTimeline = createMockTimeline(24);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: rootTimeline,
hook: hookTimeline,
tweet: tweetTimeline,
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { renderSeek: (timeSeconds: number) => void };
}
).__player;
expect(player).toBeDefined();
// Simulate that the hook timeline was paused (as happens when
// children are added to a paused root timeline in GSAP)
hookTimeline.paused!(true);
tweetTimeline.paused!(true);
// Seek to 0.5s — well within the hook's window [0.001, 2.001]
player?.renderSeek(0.5);
// renderSeek should activate (unpause) all child timelines before
// seeking the root. Without the fix, children stay paused and GSAP's
// totalTime() propagation skips them, leaving elements at initial CSS
// state (opacity: 0).
expect(hookTimeline.paused!()).toBe(false);
expect(tweetTimeline.paused!()).toBe(false);
// The hook host should be visible at t=0.5
expect(hookHost.style.visibility).toBe("visible");
});
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
+33 -2
View File
@@ -1724,9 +1724,40 @@ export function initSandboxRuntimeModular(): void {
}
};
const seekTimelineAndAdapters = (t: number) => {
// Unpause all non-root timelines registered in window.__timelines (siblings
// in the registry, not GSAP child tweens). Matches the naming convention in
// player.ts:32 (forEachSiblingTimeline) and player.ts:89 (activateSiblingTimelines).
//
// Unlike the player's seek path which re-pauses siblings after seeking,
// render-seek is one-frame-at-a-time with no transport tick between frames,
// so the residual unpaused state is harmless — the next call re-activates
// idempotently.
const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
for (const tl of Object.values(timelines)) {
if (!tl || tl === masterTimeline) continue;
try {
tl.play();
} catch (err) {
swallow("runtime.init.activateSiblings", err);
}
}
};
const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => {
const tl = state.capturedTimeline;
if (tl) {
// When rendering frame-by-frame (activateChildren=true), ensure all
// sibling timelines are unpaused before seeking the root. GSAP
// does not propagate totalTime() to children that are internally
// paused, which leaves sub-compositions at their initial CSS state
// (typically opacity:0). This mirrors the activateSiblingTimelines
// call in player.ts renderSeek and is critical for sub-compositions
// whose data-start is at or near 0 — they are added to the root
// while it is paused and may never receive an explicit play().
if (opts?.activateChildren) {
activateSiblingTimelines(tl);
}
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(t, false);
@@ -2001,7 +2032,7 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
seekTimelineAndAdapters(state.currentTime);
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
syncMediaForCurrentState();
postState(true);
};
+8 -7
View File
@@ -365,14 +365,15 @@ async function pollSubCompositionTimelines(
}
return true;
})()`;
const timelinesBeforePoll = Number(
await page.evaluate(`Object.keys(window.__timelines || {}).length`),
);
const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
const timelinesAfterPoll = Number(
await page.evaluate(`Object.keys(window.__timelines || {}).length`),
);
if (ready && timelinesAfterPoll > timelinesBeforePoll) {
// Always force a timeline rebind once sub-composition timelines are
// confirmed present. The previous implementation only called rebind
// when the timeline count grew during the poll, which missed the case
// where all sub-comp scripts had already executed before the poll
// started — leaving child timelines un-nested in the root and causing
// the earliest sub-composition (data-start near 0) to render without
// its GSAP animations.
if (ready) {
await page.evaluate(`(function() {
if (typeof window.__hfForceTimelineRebind === "function") {
window.__hfForceTimelineRebind();
@@ -0,0 +1,12 @@
{
"name": "Sub-composition #ID selector scoping",
"description": "Documents that sub-compositions using #ID selectors may render differently between preview and render due to the producer stripping the inner root element. Workaround: use [data-composition-id] selectors instead of #ID.",
"tags": ["sub-composition", "regression", "selector"],
"minPsnr": 20,
"maxFrameFailures": 10,
"minAudioCorrelation": 0.0,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 24
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed6ecf20f2b3a6074c0fe828591f2d6566e2ed7496fbfa7b4272e78c6a813011
size 55844
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
.scene {
position: absolute;
inset: 0;
overflow: hidden;
}
#scene-intro {
opacity: 1;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="4"
data-width="1920"
data-height="1080"
>
<div
id="scene-intro"
class="scene"
data-composition-id="intro"
data-composition-src="intro.html"
data-start="0"
data-duration="4"
data-track-index="0"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
@@ -0,0 +1,42 @@
<template id="intro-template">
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
<div
class="title"
style="
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 120px;
font-family: Impact, sans-serif;
color: #fff;
opacity: 0;
"
>
HELLO WORLD
</div>
<style>
#intro {
position: relative;
width: 1920px;
height: 1080px;
background: #111;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var scope = "#intro";
tl.fromTo(
scope + " .title",
{ opacity: 0, y: 50, scale: 0.8 },
{ opacity: 1, y: 0, scale: 1, duration: 0.5, ease: "power3.out" },
0.2,
);
window.__timelines["intro"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,12 @@
{
"name": "Sub-composition timeline activation at t=0",
"description": "Regression for: sub-composition timelines at data-start near 0 were not activated during renderSeek, causing GSAP-animated elements to stay invisible. The fix activates nested child timelines before every render frame seek.",
"tags": ["sub-composition", "regression", "timeline"],
"minPsnr": 25,
"maxFrameFailures": 5,
"minAudioCorrelation": 0.0,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 24
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac5c0ef3b7e6e31a49d361a62ba2ca8ac1532544eca9f6f60e0ede52836693c
size 20587
@@ -0,0 +1,61 @@
<template id="hook-template">
<div id="hook" data-composition-id="hook" data-width="1920" data-height="1080">
<div class="hook-title">BREAKING</div>
<div class="hook-subtitle">Transfer confirmed</div>
<style>
#hook {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #07110d;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
}
#hook .hook-title {
font-family: Impact, system-ui, sans-serif;
font-size: 200px;
font-weight: 900;
color: #ff3b30;
opacity: 0;
}
#hook .hook-subtitle {
font-family: Arial, system-ui, sans-serif;
font-size: 60px;
color: #f7f3e8;
opacity: 0;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var scope = "#hook";
// Title slams in at 0.1s
tl.fromTo(
scope + " .hook-title",
{ scale: 3.5, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.25, ease: "expo.out" },
0.1,
);
// Subtitle fades in at 0.5s
tl.fromTo(
scope + " .hook-subtitle",
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.4, ease: "power3.out" },
0.5,
);
window.__timelines["hook"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,43 @@
<template id="later-template">
<div id="later" data-composition-id="later" data-width="1920" data-height="1080">
<div class="later-heading">Second Scene</div>
<style>
#later {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0e2c1e;
display: flex;
align-items: center;
justify-content: center;
}
#later .later-heading {
font-family: Arial, system-ui, sans-serif;
font-size: 120px;
font-weight: 700;
color: #f7f3e8;
opacity: 0;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var scope = "#later";
tl.fromTo(
scope + " .later-heading",
{ y: 50, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.5, ease: "power3.out" },
0.2,
);
window.__timelines["later"] = tl;
})();
</script>
</div>
</template>
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #07110d;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #07110d;
}
.scene {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0;
}
#scene-hook {
opacity: 1;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="6"
data-width="1920"
data-height="1080"
>
<!-- First sub-comp at near-zero start: the bug trigger -->
<div
id="scene-hook"
class="scene"
data-layout-allow-overflow
data-composition-id="hook"
data-composition-src="compositions/hook.html"
data-start="0.001"
data-duration="2.0"
data-track-index="0"
></div>
<!-- Second sub-comp at a later start: works correctly -->
<div
id="scene-later"
class="scene"
data-layout-allow-overflow
data-composition-id="later"
data-composition-src="compositions/later.html"
data-start="2.5"
data-duration="3.5"
data-track-index="1"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// Transition: hook zooms out at 2.5s
tl.to("#scene-hook", { scale: 2.5, opacity: 0, duration: 0.4, ease: "power3.in" }, 2.5);
tl.fromTo(
"#scene-later",
{ scale: 0.5, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.4, ease: "power3.out" },
2.65,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>