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