Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ jobs:
- shard: shard-6
args: "overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence"
- shard: shard-7
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric"
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector"
- shard: shard-8
args: "style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat"
steps:
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]);
});
});
154 changes: 154 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 6 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
35 changes: 33 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
};
Expand Down
15 changes: 8 additions & 7 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading