Skip to content

Commit 83e01e0

Browse files
Merge pull request #965 from heygen-com/fix/sub-comp-timeline-t0
fix: activate nested child timelines on renderSeek (sub-comp at t=0)
2 parents a48350b + d16e5d6 commit 83e01e0

18 files changed

Lines changed: 1828 additions & 10 deletions

File tree

.github/workflows/regression.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
- shard: shard-6
7373
args: "overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence"
7474
- shard: shard-7
75-
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric"
75+
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector"
7676
- shard: shard-8
7777
args: "style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat"
7878
steps:

packages/core/src/compiler/compositionScoping.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,4 +496,72 @@ window.__afterTimeline = window.__timelines.scene;
496496
expect(fakeWindow.__afterTimeline).toBe("updated");
497497
expect(errorSpy).not.toHaveBeenCalled();
498498
});
499+
500+
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
501+
const scoped = scopeCssToComposition(
502+
`#intro { background: #111; }
503+
#intro .title { font-size: 120px; color: #fff; }`,
504+
"intro",
505+
undefined,
506+
"intro",
507+
);
508+
509+
// #intro should become [data-hf-authored-id="intro"]
510+
expect(scoped).toContain('[data-hf-authored-id="intro"]');
511+
expect(scoped).toContain('[data-hf-authored-id="intro"] .title');
512+
// Raw #intro selectors should be gone
513+
expect(scoped).not.toMatch(/#intro\b/);
514+
});
515+
516+
it('does not rewrite [id="intro"] attribute selectors', () => {
517+
// The function only targets #intro hash selectors, not [id="intro"] attribute selectors
518+
const result = scopeCssToComposition(
519+
'[id="intro"] .title { color: red; }',
520+
"intro",
521+
undefined,
522+
"intro",
523+
);
524+
expect(result).toContain('[id="intro"]');
525+
});
526+
527+
it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
528+
const { document } = parseHTML(`
529+
<div data-composition-id="intro">
530+
<div data-hf-authored-id="intro">
531+
<div class="title">HELLO</div>
532+
</div>
533+
</div>
534+
`);
535+
const gsapTargets: string[][] = [];
536+
const fakeWindow = {
537+
document,
538+
__timelines: {},
539+
gsap: {
540+
timeline: () => ({
541+
fromTo(targets: Element[], _from: unknown, _to: unknown) {
542+
gsapTargets.push(Array.from(targets).map((t) => t.textContent || ""));
543+
return this;
544+
},
545+
}),
546+
},
547+
};
548+
const wrapped = wrapScopedCompositionScript(
549+
`
550+
var tl = gsap.timeline({ paused: true });
551+
tl.fromTo('#intro .title', { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.2);
552+
window.__timelines['intro'] = tl;
553+
`,
554+
"intro",
555+
"[HyperFrames] composition script error:",
556+
undefined,
557+
"intro",
558+
"intro",
559+
);
560+
561+
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
562+
563+
// The scoped script should resolve '#intro .title' against the
564+
// data-hf-authored-id="intro" element, finding the .title child.
565+
expect(gsapTargets).toEqual([["HELLO"]]);
566+
});
499567
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseHTML } from "linkedom";
3+
import { inlineSubCompositions } from "./inlineSubCompositions";
4+
5+
// Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked.
6+
7+
/**
8+
* Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope.
9+
* This is the pattern that breaks when the producer path strips the inner root.
10+
*/
11+
const SUB_COMP_HTML = `<template id="intro-template">
12+
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
13+
<div class="title" style="opacity:0;">HELLO WORLD</div>
14+
<style>
15+
#intro { position:relative; width:1920px; height:1080px; background:#111; }
16+
#intro .title { font-size:120px; color:#fff; }
17+
</style>
18+
<script>
19+
(function() {
20+
window.__timelines = window.__timelines || {};
21+
var tl = gsap.timeline({ paused: true });
22+
tl.fromTo('#intro .title', { opacity:0 }, { opacity:1, duration:0.5 }, 0.2);
23+
window.__timelines['intro'] = tl;
24+
})();
25+
</script>
26+
</div>
27+
</template>`;
28+
29+
function makeHostDocument(compId: string) {
30+
const { document } = parseHTML(`<!DOCTYPE html>
31+
<html><body>
32+
<div data-composition-id="main">
33+
<div data-composition-id="${compId}" data-composition-src="intro.html"
34+
data-start="0" data-duration="4" data-track-index="0"></div>
35+
</div>
36+
</body></html>`);
37+
return document;
38+
}
39+
40+
describe("inlineSubCompositions – #ID selector scoping divergence", () => {
41+
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
42+
const document = makeHostDocument("intro");
43+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
44+
45+
const result = inlineSubCompositions(document, [host], {
46+
resolveHtml: () => SUB_COMP_HTML,
47+
parseHtml: (html) => parseHTML(html).document,
48+
});
49+
50+
// The producer path takes innerHTML when compId matches, stripping the
51+
// wrapper <div id="intro" ...>. The host element should NOT contain a
52+
// child with id="intro" — the id attribute is lost.
53+
const innerRootById = host.querySelector("#intro");
54+
expect(innerRootById).toBeNull();
55+
56+
// The host itself still has data-composition-id="intro" (from the
57+
// original markup), but no element inside has id="intro".
58+
expect(host.getAttribute("data-composition-id")).toBe("intro");
59+
60+
// CSS was scoped: #intro selectors should be rewritten to use
61+
// data-hf-authored-id attribute selector so they still resolve.
62+
const scopedCss = result.styles.join("\n");
63+
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
64+
expect(scopedCss).not.toContain("#intro");
65+
});
66+
67+
it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
68+
const document = makeHostDocument("intro");
69+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
70+
71+
const result = inlineSubCompositions(document, [host], {
72+
resolveHtml: () => SUB_COMP_HTML,
73+
parseHtml: (html) => parseHTML(html).document,
74+
});
75+
76+
// The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
77+
// so that the selector resolves against the flattened structure.
78+
const scopedCss = result.styles.join("\n");
79+
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
80+
expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
81+
});
82+
83+
it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
84+
const document = makeHostDocument("intro");
85+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
86+
87+
const result = inlineSubCompositions(document, [host], {
88+
resolveHtml: () => SUB_COMP_HTML,
89+
parseHtml: (html) => parseHTML(html).document,
90+
});
91+
92+
// The wrapped script should contain the authored root id normalization
93+
// logic so that runtime querySelector('#intro .title') maps to the
94+
// data-hf-authored-id attribute selector.
95+
const wrappedScript = result.scripts.join("\n");
96+
expect(wrappedScript).toContain("__hfAuthoredRootId");
97+
expect(wrappedScript).toContain('"intro"');
98+
});
99+
100+
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
101+
const document = makeHostDocument("intro");
102+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
103+
104+
// Simulate the bundler's flattenInnerRoot: clone the element, add
105+
// data-hf-authored-id, strip timing attrs (simplified here).
106+
function flattenInnerRoot(innerRoot: Element): Element {
107+
const clone = innerRoot.cloneNode(true) as Element;
108+
const authoredId = clone.getAttribute("id");
109+
if (authoredId) {
110+
clone.setAttribute("data-hf-authored-id", authoredId);
111+
clone.removeAttribute("id");
112+
}
113+
clone.removeAttribute("data-start");
114+
clone.removeAttribute("data-duration");
115+
return clone;
116+
}
117+
118+
const result = inlineSubCompositions(document, [host], {
119+
resolveHtml: () => SUB_COMP_HTML,
120+
parseHtml: (html) => parseHTML(html).document,
121+
flattenInnerRoot,
122+
});
123+
124+
// With flattenInnerRoot, the inner root is preserved as a child of the
125+
// host via outerHTML. The data-hf-authored-id attribute is present.
126+
const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
127+
expect(authoredRoot).not.toBeNull();
128+
129+
// CSS is still rewritten to use the attribute selector.
130+
const scopedCss = result.styles.join("\n");
131+
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
132+
});
133+
134+
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
135+
const document = makeHostDocument("intro");
136+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
137+
138+
inlineSubCompositions(document, [host], {
139+
resolveHtml: () => SUB_COMP_HTML,
140+
parseHtml: (html) => parseHTML(html).document,
141+
});
142+
143+
// The inner root's id="intro" is stripped (innerHTML), but the producer
144+
// now propagates it as data-hf-authored-id on the host element so that
145+
// rewritten #ID selectors ([data-hf-authored-id="intro"]) resolve.
146+
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
147+
148+
// The original #intro element is still gone — innerHTML stripped it.
149+
const introById = host.querySelector("#intro");
150+
expect(introById).toBeNull();
151+
152+
expect(host.getAttribute("data-composition-id")).toBe("intro");
153+
});
154+
});

packages/core/src/compiler/inlineSubCompositions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,12 @@ export function inlineSubCompositions(
305305
hostEl.innerHTML = prepared.outerHTML || "";
306306
} else {
307307
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
308+
// When the producer path strips the inner root (innerHTML), the
309+
// authored id attribute is lost. Propagate it to the host so that
310+
// rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
311+
if (compId && authoredRootId) {
312+
hostEl.setAttribute("data-hf-authored-id", authoredRootId);
313+
}
308314
}
309315
} else {
310316
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();

packages/core/src/runtime/init.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,75 @@ describe("initSandboxRuntimeModular", () => {
444444
expect(video.currentTime).toBe(0);
445445
});
446446

447+
it("activates sub-composition timelines at data-start near 0 during renderSeek", () => {
448+
// Regression: sub-compositions starting at or near t=0 had their GSAP
449+
// sub-timelines ignored during render because renderSeek did not
450+
// activate (unpause) nested child timelines before seeking the root.
451+
// The children were added to the root while paused, and GSAP's
452+
// totalTime() does not propagate to paused children.
453+
const root = document.createElement("div");
454+
root.setAttribute("data-composition-id", "main");
455+
root.setAttribute("data-root", "true");
456+
root.setAttribute("data-start", "0");
457+
root.setAttribute("data-duration", "24");
458+
root.setAttribute("data-width", "1920");
459+
root.setAttribute("data-height", "1080");
460+
document.body.appendChild(root);
461+
462+
const hookHost = document.createElement("div");
463+
hookHost.setAttribute("data-composition-id", "hook");
464+
hookHost.setAttribute("data-start", "0.001");
465+
hookHost.setAttribute("data-duration", "2");
466+
hookHost.setAttribute("data-track-index", "0");
467+
hookHost.classList.add("clip");
468+
root.appendChild(hookHost);
469+
470+
const laterHost = document.createElement("div");
471+
laterHost.setAttribute("data-composition-id", "tweet");
472+
laterHost.setAttribute("data-start", "1.5");
473+
laterHost.setAttribute("data-duration", "4.5");
474+
laterHost.setAttribute("data-track-index", "1");
475+
laterHost.classList.add("clip");
476+
root.appendChild(laterHost);
477+
478+
const hookTimeline = createMockTimeline(2);
479+
const tweetTimeline = createMockTimeline(4.5);
480+
const rootTimeline = createMockTimeline(24);
481+
482+
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
483+
main: rootTimeline,
484+
hook: hookTimeline,
485+
tweet: tweetTimeline,
486+
};
487+
488+
initSandboxRuntimeModular();
489+
490+
const player = (
491+
window as Window & {
492+
__player?: { renderSeek: (timeSeconds: number) => void };
493+
}
494+
).__player;
495+
expect(player).toBeDefined();
496+
497+
// Simulate that the hook timeline was paused (as happens when
498+
// children are added to a paused root timeline in GSAP)
499+
hookTimeline.paused!(true);
500+
tweetTimeline.paused!(true);
501+
502+
// Seek to 0.5s — well within the hook's window [0.001, 2.001]
503+
player?.renderSeek(0.5);
504+
505+
// renderSeek should activate (unpause) all child timelines before
506+
// seeking the root. Without the fix, children stay paused and GSAP's
507+
// totalTime() propagation skips them, leaving elements at initial CSS
508+
// state (opacity: 0).
509+
expect(hookTimeline.paused!()).toBe(false);
510+
expect(tweetTimeline.paused!()).toBe(false);
511+
512+
// The hook host should be visible at t=0.5
513+
expect(hookHost.style.visibility).toBe("visible");
514+
});
515+
447516
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
448517
const raf = createManualRaf();
449518
vi.spyOn(performance, "now").mockImplementation(() => raf.now());

packages/core/src/runtime/init.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,9 +1724,40 @@ export function initSandboxRuntimeModular(): void {
17241724
}
17251725
};
17261726

1727-
const seekTimelineAndAdapters = (t: number) => {
1727+
// Unpause all non-root timelines registered in window.__timelines (siblings
1728+
// in the registry, not GSAP child tweens). Matches the naming convention in
1729+
// player.ts:32 (forEachSiblingTimeline) and player.ts:89 (activateSiblingTimelines).
1730+
//
1731+
// Unlike the player's seek path which re-pauses siblings after seeking,
1732+
// render-seek is one-frame-at-a-time with no transport tick between frames,
1733+
// so the residual unpaused state is harmless — the next call re-activates
1734+
// idempotently.
1735+
const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike) => {
1736+
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
1737+
for (const tl of Object.values(timelines)) {
1738+
if (!tl || tl === masterTimeline) continue;
1739+
try {
1740+
tl.play();
1741+
} catch (err) {
1742+
swallow("runtime.init.activateSiblings", err);
1743+
}
1744+
}
1745+
};
1746+
1747+
const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => {
17281748
const tl = state.capturedTimeline;
17291749
if (tl) {
1750+
// When rendering frame-by-frame (activateChildren=true), ensure all
1751+
// sibling timelines are unpaused before seeking the root. GSAP
1752+
// does not propagate totalTime() to children that are internally
1753+
// paused, which leaves sub-compositions at their initial CSS state
1754+
// (typically opacity:0). This mirrors the activateSiblingTimelines
1755+
// call in player.ts renderSeek and is critical for sub-compositions
1756+
// whose data-start is at or near 0 — they are added to the root
1757+
// while it is paused and may never receive an explicit play().
1758+
if (opts?.activateChildren) {
1759+
activateSiblingTimelines(tl);
1760+
}
17301761
try {
17311762
if (typeof tl.totalTime === "function") {
17321763
tl.totalTime(t, false);
@@ -2001,7 +2032,7 @@ export function initSandboxRuntimeModular(): void {
20012032
state.currentTime = clock.now();
20022033
state.isPlaying = false;
20032034
state.mediaForceSyncNextTick = true;
2004-
seekTimelineAndAdapters(state.currentTime);
2035+
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
20052036
syncMediaForCurrentState();
20062037
postState(true);
20072038
};

packages/engine/src/services/frameCapture.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -365,14 +365,15 @@ async function pollSubCompositionTimelines(
365365
}
366366
return true;
367367
})()`;
368-
const timelinesBeforePoll = Number(
369-
await page.evaluate(`Object.keys(window.__timelines || {}).length`),
370-
);
371368
const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
372-
const timelinesAfterPoll = Number(
373-
await page.evaluate(`Object.keys(window.__timelines || {}).length`),
374-
);
375-
if (ready && timelinesAfterPoll > timelinesBeforePoll) {
369+
// Always force a timeline rebind once sub-composition timelines are
370+
// confirmed present. The previous implementation only called rebind
371+
// when the timeline count grew during the poll, which missed the case
372+
// where all sub-comp scripts had already executed before the poll
373+
// started — leaving child timelines un-nested in the root and causing
374+
// the earliest sub-composition (data-start near 0) to render without
375+
// its GSAP animations.
376+
if (ready) {
376377
await page.evaluate(`(function() {
377378
if (typeof window.__hfForceTimelineRebind === "function") {
378379
window.__hfForceTimelineRebind();

0 commit comments

Comments
 (0)