mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user