mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix: producer render diverges from preview for sub-composition root styling (#1886)
Fixes #1847 The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview. Changes: - Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's. - Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding. - Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts). - Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts). Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review. Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
This commit is contained in:
@@ -609,6 +609,71 @@ window.__afterTimeline = window.__timelines.scene;
|
|||||||
expect(scoped).not.toMatch(/#intro\b/);
|
expect(scoped).not.toMatch(/#intro\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rewrites a bare root [data-composition-id] box selector to target exactly one of host or wrapper", () => {
|
||||||
|
// A composition styling its own box (e.g. `display:flex` to center its
|
||||||
|
// children, or `padding` to offset it) via the bare composition-id
|
||||||
|
// selector. After flattenInnerRoot preserves the authored root as a
|
||||||
|
// wrapper below the host, that wrapper (marked data-hf-inner-root) is
|
||||||
|
// what actually parents the real children, so the box styling must land
|
||||||
|
// there instead of the host. It must land on exactly one of the two:
|
||||||
|
// targeting both would apply an additive property like `padding` twice,
|
||||||
|
// since the wrapper is nested inside the host.
|
||||||
|
const scoped = scopeCssToComposition(
|
||||||
|
'[data-composition-id="captions"] { display: flex; justify-content: center; }',
|
||||||
|
"captions",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scoped).toContain(
|
||||||
|
'[data-composition-id="captions"]:not(:has([data-hf-inner-root])), ' +
|
||||||
|
'[data-composition-id="captions"] > [data-hf-inner-root]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches exactly the wrapper (not the host too) when both exist in the flattened DOM shape", () => {
|
||||||
|
// Regression test: an earlier version of this fix targeted both the host
|
||||||
|
// and the wrapper (a plain OR), which doubles any additive property
|
||||||
|
// (e.g. padding-top) since the wrapper is nested inside the host.
|
||||||
|
const scoped = scopeCssToComposition(
|
||||||
|
'[data-composition-id="captions"] { padding-top: 200px; }',
|
||||||
|
"captions",
|
||||||
|
);
|
||||||
|
const ruleMatch = scoped.match(/([^{]+)\{/);
|
||||||
|
const selectorText = ruleMatch?.[1]?.trim();
|
||||||
|
if (!selectorText) throw new Error("expected a CSS rule to be produced");
|
||||||
|
|
||||||
|
const { document } = parseHTML(
|
||||||
|
'<div id="host" data-composition-id="captions">' +
|
||||||
|
'<div id="wrapper" data-hf-inner-root="true"></div>' +
|
||||||
|
"</div>",
|
||||||
|
);
|
||||||
|
const matches = [...document.querySelectorAll(selectorText)];
|
||||||
|
expect(matches.map((el) => el.id)).toEqual(["wrapper"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the host when no wrapper is present (non-flattened fallback)", () => {
|
||||||
|
const scoped = scopeCssToComposition(
|
||||||
|
'[data-composition-id="captions"] { padding-top: 200px; }',
|
||||||
|
"captions",
|
||||||
|
);
|
||||||
|
const ruleMatch = scoped.match(/([^{]+)\{/);
|
||||||
|
const selectorText = ruleMatch?.[1]?.trim();
|
||||||
|
if (!selectorText) throw new Error("expected a CSS rule to be produced");
|
||||||
|
|
||||||
|
const { document } = parseHTML('<div id="host" data-composition-id="captions"></div>');
|
||||||
|
const matches = [...document.querySelectorAll(selectorText)];
|
||||||
|
expect(matches.map((el) => el.id)).toEqual(["host"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves root-plus-descendant [data-composition-id] selectors as a plain scope prefix", () => {
|
||||||
|
const scoped = scopeCssToComposition(
|
||||||
|
'[data-composition-id="captions"] .title { color: red; }',
|
||||||
|
"captions",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scoped).toContain('[data-composition-id="captions"] .title');
|
||||||
|
expect(scoped).not.toContain("data-hf-inner-root");
|
||||||
|
});
|
||||||
|
|
||||||
it('does not rewrite [id="intro"] attribute selectors', () => {
|
it('does not rewrite [id="intro"] attribute selectors', () => {
|
||||||
// The function only targets #intro hash selectors, not [id="intro"] attribute selectors
|
// The function only targets #intro hash selectors, not [id="intro"] attribute selectors
|
||||||
const result = scopeCssToComposition(
|
const result = scopeCssToComposition(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import postcss, { type AtRule, type Node, type Rule } from "postcss";
|
import postcss, { type AtRule, type Node, type Rule } from "postcss";
|
||||||
|
|
||||||
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
|
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
|
||||||
|
const INNER_ROOT_ATTR = "data-hf-inner-root";
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
@@ -117,6 +118,18 @@ function scopeSelector(
|
|||||||
"g",
|
"g",
|
||||||
);
|
);
|
||||||
if (compositionIdPattern.test(trimmed)) {
|
if (compositionIdPattern.test(trimmed)) {
|
||||||
|
const isRootBoxSelector = trimmed.replace(compositionIdPattern, "").trim() === "";
|
||||||
|
if (isRootBoxSelector) {
|
||||||
|
// A bare root selector styles the composition's own box (flex/grid/
|
||||||
|
// position/padding). When flattenInnerRoot preserves the authored root
|
||||||
|
// as a wrapper below `scope` (see prepareFlattenedInnerRoot), that
|
||||||
|
// wrapper is the element real children are laid out in, not `scope`
|
||||||
|
// itself, so the box styling must land there instead. It must land on
|
||||||
|
// exactly one of the two: applying it to both compounds any additive
|
||||||
|
// property (padding, margin, non-zero transform) since the wrapper
|
||||||
|
// sits nested inside the host and would inherit the effect twice.
|
||||||
|
return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`;
|
||||||
|
}
|
||||||
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
|
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
|
||||||
}
|
}
|
||||||
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
|
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
|
||||||
|
|||||||
@@ -175,6 +175,52 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
|
|||||||
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
|
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("with flattenInnerRoot: restores data-composition-id on the wrapper for an anonymous host", () => {
|
||||||
|
// Regression test: a host mounted via data-composition-src with no
|
||||||
|
// data-composition-id of its own (an "anonymous" host). The composition
|
||||||
|
// styles its own root box via the bare composition-id selector and a
|
||||||
|
// script self-references it too — both need something in the render DOM
|
||||||
|
// to actually carry that id once flattenInnerRoot strips it from the
|
||||||
|
// wrapper by default.
|
||||||
|
const { document } = parseHTML(`<!DOCTYPE html>
|
||||||
|
<html><body>
|
||||||
|
<div data-composition-id="main">
|
||||||
|
<div data-composition-src="scoped-text.html" data-start="0" data-duration="3"></div>
|
||||||
|
</div>
|
||||||
|
</body></html>`);
|
||||||
|
const host = document.querySelector('[data-composition-src="scoped-text.html"]')!;
|
||||||
|
|
||||||
|
const scopedTextHtml = `<template id="scoped-text-template">
|
||||||
|
<div data-composition-id="scoped-text" data-width="1080" data-height="1920" data-duration="3">
|
||||||
|
<div class="label">Scoped Text Should Stay Styled</div>
|
||||||
|
<style>
|
||||||
|
[data-composition-id="scoped-text"] { display: flex; background: rgb(12, 12, 12); }
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</template>`;
|
||||||
|
|
||||||
|
function flattenInnerRoot(innerRoot: Element): Element {
|
||||||
|
const clone = innerRoot.cloneNode(true) as Element;
|
||||||
|
clone.removeAttribute("data-composition-id");
|
||||||
|
clone.removeAttribute("data-start");
|
||||||
|
clone.removeAttribute("data-duration");
|
||||||
|
clone.setAttribute("data-hf-inner-root", "true");
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = inlineSubCompositions(document, [host], {
|
||||||
|
resolveHtml: () => scopedTextHtml,
|
||||||
|
parseHtml: (html) => parseHTML(html).document,
|
||||||
|
flattenInnerRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = host.querySelector("[data-hf-inner-root]");
|
||||||
|
expect(wrapper?.getAttribute("data-composition-id")).toBe("scoped-text");
|
||||||
|
|
||||||
|
const scopedCss = result.styles.join("\n");
|
||||||
|
expect(scopedCss).toContain("display: flex");
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts <link> elements from sub-composition <head> with original rel and crossorigin", () => {
|
it("extracts <link> elements from sub-composition <head> with original rel and crossorigin", () => {
|
||||||
const subCompWithLinks = `<!doctype html>
|
const subCompWithLinks = `<!doctype html>
|
||||||
<html><head>
|
<html><head>
|
||||||
|
|||||||
@@ -372,6 +372,16 @@ export function inlineSubCompositions(
|
|||||||
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
||||||
if (flattenInnerRoot) {
|
if (flattenInnerRoot) {
|
||||||
const prepared = flattenInnerRoot(innerRoot);
|
const prepared = flattenInnerRoot(innerRoot);
|
||||||
|
if (!compId && inferredCompId) {
|
||||||
|
// Anonymous host: flattenInnerRoot strips data-composition-id,
|
||||||
|
// assuming the host already carries the composition's identity.
|
||||||
|
// When the host has none, nothing in the render DOM matches the
|
||||||
|
// composition's own root-styling CSS or self-referencing scripts
|
||||||
|
// (e.g. document.querySelector('[data-composition-id="X"]')).
|
||||||
|
// Restore it on the wrapper so both keep resolving, same as
|
||||||
|
// before flattening preserved it via outerHTML.
|
||||||
|
prepared.setAttribute("data-composition-id", inferredCompId);
|
||||||
|
}
|
||||||
hostEl.innerHTML = prepared.outerHTML || "";
|
hostEl.innerHTML = prepared.outerHTML || "";
|
||||||
} else {
|
} else {
|
||||||
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
|
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
|
||||||
|
|||||||
@@ -972,6 +972,43 @@ describe("loadExternalCompositions", () => {
|
|||||||
expect(byCompAfterSecondMount?.["card-last"]).toBeUndefined();
|
expect(byCompAfterSecondMount?.["card-last"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves data-composition-id unflattened for a host with no id of its own (anonymous host)", async () => {
|
||||||
|
// Regression test documenting why this file's own prepareFlattenedInnerRoot
|
||||||
|
// (line ~527) does NOT need the same anonymous-host id-restoration that
|
||||||
|
// producer/bundler compilation needed: an anonymous host's authoredCompositionId
|
||||||
|
// is null, so mountCompositionContent's innerRoot lookup never runs, and it
|
||||||
|
// falls through to a raw document.importNode() of the whole template content
|
||||||
|
// instead of prepareFlattenedInnerRoot. The composition's own
|
||||||
|
// data-composition-id is never stripped in the first place, so its root-styling
|
||||||
|
// CSS and self-referencing querySelector('[data-composition-id="X"]') calls
|
||||||
|
// already resolve. See PR review discussion on #1886 for the audit trail.
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.setAttribute("data-composition-src", "https://example.com/scoped-text.html");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const compositionHtml = `
|
||||||
|
<template id="scoped-text-template">
|
||||||
|
<div data-composition-id="scoped-text" data-width="1080" data-height="1920">
|
||||||
|
<div class="label">Scoped Text Should Stay Styled</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||||
|
|
||||||
|
await loadExternalCompositions({ ...defaultParams });
|
||||||
|
|
||||||
|
// Not flattened: no data-hf-inner-root wrapper was created.
|
||||||
|
expect(host.querySelector("[data-hf-inner-root]")).toBeNull();
|
||||||
|
// The composition's own root element, with its own id intact, is a
|
||||||
|
// direct descendant of the (still anonymous) host.
|
||||||
|
const mountedRoot = host.querySelector('[data-composition-id="scoped-text"]');
|
||||||
|
expect(mountedRoot).not.toBeNull();
|
||||||
|
expect(mountedRoot?.querySelector(".label")?.textContent).toBe(
|
||||||
|
"Scoped Text Should Stay Styled",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadInlineTemplateCompositions", () => {
|
describe("loadInlineTemplateCompositions", () => {
|
||||||
|
|||||||
@@ -178,6 +178,39 @@ describe("createRuntimeStartTimeResolver", () => {
|
|||||||
expect(resolver.resolveStartForElement(video)).toBe(54);
|
expect(resolver.resolveStartForElement(video)).toBe(54);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("walks up to the host's data-start when the inner root has none (host has its own data-composition-id)", () => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.setAttribute("data-composition-id", "montage");
|
||||||
|
host.setAttribute("data-start", "10");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const innerRoot = document.createElement("div");
|
||||||
|
innerRoot.setAttribute("data-composition-id", "scene-10");
|
||||||
|
host.appendChild(innerRoot);
|
||||||
|
|
||||||
|
const resolver = createRuntimeStartTimeResolver({});
|
||||||
|
expect(resolver.resolveStartForElement(innerRoot)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks up to the host's data-start via data-composition-file (anonymous host, post-inlining)", () => {
|
||||||
|
// A host mounted via data-composition-src with no data-composition-id of
|
||||||
|
// its own. After inlining, data-composition-src is stripped and replaced
|
||||||
|
// with data-composition-file, and the composition's own id is restored
|
||||||
|
// onto the wrapper (which has no data-start of its own).
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.setAttribute("data-composition-file", "compositions/reveal1.html");
|
||||||
|
host.setAttribute("data-start", "4.619");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.setAttribute("data-composition-id", "reveal1");
|
||||||
|
wrapper.setAttribute("data-hf-inner-root", "true");
|
||||||
|
host.appendChild(wrapper);
|
||||||
|
|
||||||
|
const resolver = createRuntimeStartTimeResolver({});
|
||||||
|
expect(resolver.resolveStartForElement(wrapper)).toBe(4.619);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps nested references in the host composition timeline", () => {
|
it("keeps nested references in the host composition timeline", () => {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
host.id = "slide-5";
|
host.id = "slide-5";
|
||||||
|
|||||||
@@ -161,15 +161,20 @@ export function createRuntimeStartTimeResolver(params: {
|
|||||||
// If this element is a loaded composition inner root (has data-composition-id
|
// If this element is a loaded composition inner root (has data-composition-id
|
||||||
// but no data-start), walk up to the host parent which carries the actual
|
// but no data-start), walk up to the host parent which carries the actual
|
||||||
// timing. This happens when the host uses a different data-composition-id
|
// timing. This happens when the host uses a different data-composition-id
|
||||||
// than the loaded file — e.g. host="montage" but file has "scene-10".
|
// than the loaded file — e.g. host="montage" but file has "scene-10", or
|
||||||
// Check both data-composition-src (runtime) and data-composition-id (bundled,
|
// when the host itself has no data-composition-id at all (an "anonymous"
|
||||||
// where data-composition-src is stripped after inlining).
|
// host) and the composition's own id was restored onto the inlined wrapper.
|
||||||
|
// Check data-composition-src (runtime, not yet inlined), data-composition-id
|
||||||
|
// (bundled/compiled host with its own id), and data-composition-file (the
|
||||||
|
// marker every inlined host gets, compiled or bundled, once
|
||||||
|
// data-composition-src is stripped — covers the anonymous-host case).
|
||||||
if (element.hasAttribute("data-composition-id")) {
|
if (element.hasAttribute("data-composition-id")) {
|
||||||
const parent = element.parentElement;
|
const parent = element.parentElement;
|
||||||
if (
|
if (
|
||||||
parent &&
|
parent &&
|
||||||
(parent.hasAttribute("data-composition-src") ||
|
(parent.hasAttribute("data-composition-src") ||
|
||||||
parent.hasAttribute("data-composition-id"))
|
parent.hasAttribute("data-composition-id") ||
|
||||||
|
parent.hasAttribute("data-composition-file"))
|
||||||
) {
|
) {
|
||||||
const parentStart = resolveStartForElementInternal(parent, fallback);
|
const parentStart = resolveStartForElementInternal(parent, fallback);
|
||||||
startCache.set(element, parentStart);
|
startCache.set(element, parentStart);
|
||||||
|
|||||||
@@ -920,6 +920,68 @@ describe("template-wrapped sub-composition media offsets", () => {
|
|||||||
expect(compiled.html).toContain("__hfNormalizeSelector");
|
expect(compiled.html).toContain("__hfNormalizeSelector");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves a class selector on the authored root wrapper itself (issue #1847 repro)", async () => {
|
||||||
|
// The original bug report: a sub-composition root authored as
|
||||||
|
// `<div id="scene-root" class="scene-wrapper">` styled via
|
||||||
|
// `.scene-wrapper .title { color: red }`. Class-based descendant
|
||||||
|
// selectors anchored on the authored root's own class only resolve if
|
||||||
|
// the root survives as a real element in the render DOM, not just via
|
||||||
|
// id-selector rewriting to [data-hf-authored-id].
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-class-wrapper-"));
|
||||||
|
const compositionsDir = join(projectDir, "compositions");
|
||||||
|
mkdirSync(compositionsDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(projectDir, "index.html"),
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<div id="root" data-composition-id="root" data-start="0" data-width="1920" data-height="1080" data-duration="3">
|
||||||
|
<div
|
||||||
|
id="scene-host"
|
||||||
|
data-composition-id="scene"
|
||||||
|
data-composition-src="compositions/scene.html"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
window.__timelines["root"] = { duration: () => 3 };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(compositionsDir, "scene.html"),
|
||||||
|
`<template id="scene-template">
|
||||||
|
<div id="scene-root" class="scene-wrapper" data-composition-id="scene" data-width="1920" data-height="1080" data-duration="3">
|
||||||
|
<div class="title">ISSUE 1847 REPRO</div>
|
||||||
|
<style>
|
||||||
|
.scene-wrapper { background: #111; }
|
||||||
|
.scene-wrapper .title { color: red; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
window.__timelines["scene"] = { duration: () => 3 };
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</template>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
||||||
|
const { document } = parseHTML(compiled.html);
|
||||||
|
const host = document.querySelector("#scene-host");
|
||||||
|
|
||||||
|
const wrapper = host?.querySelector(".scene-wrapper");
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
expect(wrapper?.getAttribute("data-hf-authored-id")).toBe("scene-root");
|
||||||
|
expect(wrapper?.querySelector(".title")?.textContent).toBe("ISSUE 1847 REPRO");
|
||||||
|
// The authored class selector round-trips unmodified: no id rewriting
|
||||||
|
// is needed for a class selector, only the wrapper element surviving.
|
||||||
|
expect(compiled.html).toContain(".scene-wrapper .title");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the inferred composition boundary when the host has no composition id", async () => {
|
it("preserves the inferred composition boundary when the host has no composition id", async () => {
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-anonymous-host-"));
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-anonymous-host-"));
|
||||||
const compositionsDir = join(projectDir, "compositions");
|
const compositionsDir = join(projectDir, "compositions");
|
||||||
@@ -954,7 +1016,12 @@ describe("template-wrapped sub-composition media offsets", () => {
|
|||||||
const host = document.querySelector("#scene-host");
|
const host = document.querySelector("#scene-host");
|
||||||
|
|
||||||
expect(host?.getAttribute("data-composition-id")).toBeNull();
|
expect(host?.getAttribute("data-composition-id")).toBeNull();
|
||||||
expect(host?.querySelector('[data-composition-id="scene"] .title')?.textContent).toBe("Scene");
|
// The host has no data-composition-id of its own, but the composition's
|
||||||
|
// own id is restored onto the flattened wrapper, so root-scoped
|
||||||
|
// selectors and self-referencing scripts still resolve.
|
||||||
|
const wrapper = host?.querySelector("[data-hf-inner-root]");
|
||||||
|
expect(wrapper?.getAttribute("data-composition-id")).toBe("scene");
|
||||||
|
expect(wrapper?.querySelector(".title")?.textContent).toBe("Scene");
|
||||||
expect(compiled.html).toContain('var __hfCompId = "scene";');
|
expect(compiled.html).toContain('var __hfCompId = "scene";');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
type ResolvedDuration,
|
type ResolvedDuration,
|
||||||
type UnresolvedElement,
|
type UnresolvedElement,
|
||||||
} from "@hyperframes/core";
|
} from "@hyperframes/core";
|
||||||
import { inlineSubCompositions as inlineSubCompositionsShared } from "@hyperframes/core/compiler";
|
import {
|
||||||
|
inlineSubCompositions as inlineSubCompositionsShared,
|
||||||
|
prepareFlattenedInnerRoot,
|
||||||
|
} from "@hyperframes/core/compiler";
|
||||||
import {
|
import {
|
||||||
checkSubCompositionUsability,
|
checkSubCompositionUsability,
|
||||||
type ParsableDocumentLike,
|
type ParsableDocumentLike,
|
||||||
@@ -748,7 +751,14 @@ function inlineSubCompositions(
|
|||||||
},
|
},
|
||||||
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
||||||
scriptErrorLabel: "[Compiler] Composition script failed",
|
scriptErrorLabel: "[Compiler] Composition script failed",
|
||||||
compoundAuthoredRoot: true,
|
// Preserve the authored root wrapper as a child of the host, matching
|
||||||
|
// the preview bundler's shape (htmlBundler.ts's prepareFlattenedInnerRoot,
|
||||||
|
// which the runtime compositionLoader mirrors with its own copy for the
|
||||||
|
// live-loaded case). Without this, the wrapper element (and its
|
||||||
|
// class/id) is discarded and any CSS anchored on it —
|
||||||
|
// `.wrapper-class .title`, `#wrapper-id` — is dead at render time even
|
||||||
|
// though it works in preview.
|
||||||
|
flattenInnerRoot: prepareFlattenedInnerRoot as (innerRoot: Element) => Element,
|
||||||
onMissingComposition: (srcPath: string, reason?: string) => {
|
onMissingComposition: (srcPath: string, reason?: string) => {
|
||||||
// In the render path this is normally unreachable — compileForRender
|
// In the render path this is normally unreachable — compileForRender
|
||||||
// calls assertSubCompositionsUsable() before any of this runs, so a
|
// calls assertSubCompositionsUsable() before any of this runs, so a
|
||||||
@@ -761,18 +771,6 @@ function inlineSubCompositions(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set data-hf-authored-id on host elements so the scoped script proxy
|
|
||||||
// can rewrite #id selectors (e.g. #us-map → [data-hf-authored-id="us-map"]).
|
|
||||||
// Unlike flattenInnerRoot (which changes DOM structure and breaks baselines),
|
|
||||||
// this preserves the existing innerHTML-based inlining while enabling the
|
|
||||||
// authored-id selector contract.
|
|
||||||
for (const hostEl of hosts) {
|
|
||||||
const compId = hostEl.getAttribute("data-composition-id");
|
|
||||||
if (compId && !hostEl.getAttribute("data-hf-authored-id")) {
|
|
||||||
hostEl.setAttribute("data-hf-authored-id", compId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Producer-specific: set explicit pixel dimensions on host elements so
|
// Producer-specific: set explicit pixel dimensions on host elements so
|
||||||
// children using width/height: 100% resolve correctly. The runtime does
|
// children using width/height: 100% resolve correctly. The runtime does
|
||||||
// this automatically but compiled HTML needs it inline.
|
// this automatically but compiled HTML needs it inline.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Sub-composition authored-root class selector scoping",
|
||||||
|
"description": "Regression test for issue #1847 / PR #1886 (the exact reported repro): a sub-composition's authored root carries its own class (not just an id), styled via a descendant selector anchored on that class (`.scene-wrapper .title`). This diverged between preview and render because the producer discarded the authored root element entirely, so no element in the render DOM ever carried the class. The producer now preserves the authored root as a data-hf-inner-root wrapper (matching preview), so the class-based selector resolves identically in both.",
|
||||||
|
"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:22c76cc409f4567792d5f80b9c379c8905ab34cbb90bc11fe398ddf47a4fc4d7
|
||||||
|
size 33420
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template id="scene-template">
|
||||||
|
<div
|
||||||
|
id="scene-root"
|
||||||
|
class="scene-wrapper"
|
||||||
|
data-composition-id="scene"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
>
|
||||||
|
<div class="title">ISSUE 1847 REPRO</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scene-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
background: #111;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.scene-wrapper .title {
|
||||||
|
color: red;
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-family: Impact, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
window.__timelines["scene"] = gsap.timeline({ paused: true });
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!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>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 1920px;
|
||||||
|
height: 1080px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
id="root"
|
||||||
|
data-composition-id="main"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="3"
|
||||||
|
data-width="1920"
|
||||||
|
data-height="1080"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="scene-host"
|
||||||
|
data-composition-id="scene"
|
||||||
|
data-composition-src="compositions/scene.html"
|
||||||
|
data-start="0"
|
||||||
|
data-duration="3"
|
||||||
|
data-track-index="0"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
window.__timelines = window.__timelines || {};
|
||||||
|
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Sub-composition #ID selector scoping",
|
"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.",
|
"description": "Regression test for #1886: a sub-composition's authored root #ID selectors used to render differently between preview and render because the producer stripped the inner root element. The producer now preserves the authored root as a data-hf-inner-root wrapper (matching preview), and #ID selectors are rewritten to a [data-hf-authored-id] attribute on that wrapper, so #ID scoping round-trips correctly in both preview and render.",
|
||||||
"tags": ["sub-composition", "regression", "selector"],
|
"tags": ["sub-composition", "regression", "selector"],
|
||||||
"minPsnr": 20,
|
"minPsnr": 20,
|
||||||
"maxFrameFailures": 10,
|
"maxFrameFailures": 10,
|
||||||
|
|||||||
Reference in New Issue
Block a user