fix(core): a composition behind a wrapper takes its host's start (#6435)

* fix(core): a composition behind a wrapper takes its host's start

Fixes #4334.

* fix(core): a composition inside a timed wrapper starts with the wrapper

The nearest host or timed wrapper above a mounted root sets its start, so its clock starts when the wrapper shows it, matching how CSS animations already pick their clip.

* test(producer): re-pin the test classification digest
This commit is contained in:
Miguel Ángel
2026-10-26 16:48:57 +00:00
committed by GitHub
parent 0cc14ddb8e
commit 0979e32a8d
5 changed files with 142 additions and 9 deletions
@@ -212,6 +212,51 @@ describe("createRuntimeStartTimeResolver", () => {
expect(resolver.resolveStartForElement(wrapper)).toBe(4.619);
});
describe.each([
['data-composition-src="child.html"'],
['data-composition-id="host"'],
['data-composition-file="compositions/child.html"'],
])("an inner root under a %s host", (marker) => {
it.each([0, 1, 2])("takes the host's start through %i neutral wrappers", (wrappers) => {
const open = "<section>".repeat(wrappers);
const close = "</section>".repeat(wrappers);
document.body.innerHTML = `
<div data-composition-id="root" data-start="0">
<div ${marker} data-start="5">
${open}<div id="inner" data-composition-id="child"></div>${close}
</div>
</div>`;
const inner = document.getElementById("inner")!;
expect(createRuntimeStartTimeResolver({}).resolveStartForElement(inner)).toBe(5);
});
});
it("takes the nearest host, not an outer one, across a wrapper", () => {
document.body.innerHTML = `
<div data-composition-src="outer.html" data-start="2">
<div data-composition-id="outer">
<div data-composition-src="inner.html" data-start="3">
<canvas><div id="inner" data-composition-id="inner"></div></canvas>
</div>
</div>
</div>`;
const inner = document.getElementById("inner")!;
// The inner host's 3 s is local to the outer composition, which starts at 2 s.
expect(createRuntimeStartTimeResolver({}).resolveStartForElement(inner)).toBe(5);
});
it("takes a timed wrapper's start over its host's", () => {
document.body.innerHTML = `
<div data-composition-id="root" data-start="0">
<div data-composition-id="scene" data-start="5">
<section data-start="2"><div id="inner" data-composition-id="child"></div></section>
</div>
</div>`;
const inner = document.getElementById("inner")!;
// The wrapper shows the inner composition from 2 s into the scene; its clock starts then.
expect(createRuntimeStartTimeResolver({}).resolveStartForElement(inner)).toBe(7);
});
it("keeps nested references in the host composition timeline", () => {
const host = document.createElement("div");
host.id = "slide-5";
+7 -8
View File
@@ -109,18 +109,17 @@ export function createRuntimeStartTimeResolver(params: {
return resolved;
};
const COMPOSITION_HOST_SELECTOR =
"[data-composition-src], [data-composition-id], [data-composition-file]";
const isCompositionHost = (element: Element): boolean =>
element.hasAttribute("data-composition-src") ||
element.hasAttribute("data-composition-id") ||
element.hasAttribute("data-composition-file");
element.matches(COMPOSITION_HOST_SELECTOR);
// A mounted composition root without its own `data-start` takes its timing from the host it was
// loaded into: the host may use a different id than the file, or none (an anonymous host).
// A mounted composition root without its own `data-start` takes its timing from the nearest host
// or timed wrapper above it, the clip that shows it; the host may use another id than the file.
const inheritedTimingHost = (element: Element): Element | null => {
const parent = element.parentElement;
if (!parent || !element.hasAttribute("data-composition-id")) return null;
if (!element.hasAttribute("data-composition-id")) return null;
if (parseStartExpression(element.getAttribute("data-start"))) return null;
return isCompositionHost(parent) ? parent : null;
return element.parentElement?.closest(`[data-start], ${COMPOSITION_HOST_SELECTOR}`) ?? null;
};
const resolveCompositionOrigin = (compositionRoot: Element, fallback: number): number =>
@@ -37,6 +37,7 @@ const INTEGRATION_TEST_FILES = new Set([
"src/services/render/motionBlurVideo.integration.test.ts",
"src/services/render/renderCancel.integration.test.ts",
"src/services/render/segmentedColorGrading.integration.test.ts",
"src/services/render/wrappedMountTiming.integration.test.ts",
"src/services/render/stages/compileStage.mediaType.test.ts",
"src/services/render/stages/encodeWhileCapturing.test.ts",
"src/services/render/stages/gifEncodeArgs.test.ts",
@@ -0,0 +1,88 @@
// A composition nested in a timed scene through a neutral wrapper plays on the scene's clock, not
// the root's (#4334). Needs Chrome + ffmpeg: integration lane.
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveConfig } from "@hyperframes/engine";
import { createRenderJob, executeRenderJob } from "../renderOrchestrator.js";
import { HAS_FFMPEG, frameLevels } from "./frameLevelsTestFixtures.js";
const WIDTH = 160;
const HEIGHT = 90;
const FPS = 30;
// The scene starts at 1 s; the inner composition fades its box in over its own first second.
const INDEX = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="assets/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; } body { overflow: hidden; background: #000; }
#inner, #box { position: absolute; inset: 0; }
#box { background: #fff; opacity: 0; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="2"
data-width="${WIDTH}" data-height="${HEIGHT}" data-fps="${FPS}">
<div id="scene" data-composition-id="scene" data-start="1" data-duration="1" data-track-index="0"
data-width="${WIDTH}" data-height="${HEIGHT}">
<section>
<div id="inner" data-composition-id="inner" data-duration="1"
data-width="${WIDTH}" data-height="${HEIGHT}"><div id="box"></div></div>
</section>
</div>
</div>
<script>
window.__timelines["main"] = gsap.timeline({ paused: true });
window.__timelines["scene"] = gsap.timeline({ paused: true }).set({}, {}, 1);
window.__timelines["inner"] = gsap
.timeline({ paused: true })
.to("#box", { opacity: 1, duration: 1, ease: "none" });
</script>
</body>
</html>
`;
let root: string;
describe.skipIf(!HAS_FFMPEG)("a composition behind a wrapper — real render (#4334)", () => {
let levels: number[];
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hf-wrapped-mount-"));
mkdirSync(join(root, "assets"), { recursive: true });
copyFileSync(
resolve(import.meta.dirname, "../../../../core/node_modules/gsap/dist/gsap.min.js"),
join(root, "assets/gsap.min.js"),
);
writeFileSync(join(root, "index.html"), INDEX);
const job = createRenderJob({
fps: FPS,
quality: "draft",
format: "mp4",
hdrMode: "force-sdr",
workers: 1,
producerConfig: resolveConfig({ browserGpuMode: "software" }),
});
const output = join(root, "out.mp4");
await executeRenderJob(job, root, output);
levels = frameLevels(output, WIDTH, HEIGHT);
}, 120_000);
afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it("fades the box in over the scene's second, not the root's first", () => {
expect(levels).toHaveLength(2 * FPS);
expect(levels[Math.round(0.5 * FPS)]!).toBeLessThan(5);
// Half way through the scene the box is half faded in (about 128 of 255).
expect(levels[Math.round(1.5 * FPS)]!).toBeGreaterThan(90);
expect(levels[Math.round(1.5 * FPS)]!).toBeLessThan(165);
expect(levels[Math.round(1.25 * FPS)]!).toBeLessThan(levels[Math.round(1.75 * FPS)]!);
});
});
+1 -1
View File
@@ -47,7 +47,7 @@
],
"sources": {
"packages/producer/scripts/run-test-lane.mjs": "638885e14480d03290803389325a655f3749442167d784e07bb3af3404598af7",
"packages/producer/scripts/test-classification.mjs": "88d7415aac9297f65f8053873b98ae670bac7d85ea83d0e3b9685289b82ffe54",
"packages/producer/scripts/test-classification.mjs": "47a6204722443284067bf5c464601a252091aab3670ef1626e50ce507b8214fc",
".github/workflows/ci.yml#job#producer-source-tests": "45da72aa6cf08cdfd5bcf5311e89f12af4842f9eebc6735df19da8c02f4d4864",
"packages/producer/package.json#script#test:unit": "ae6beda8319d166774be958ea5593cd27dbf7f6c9bce1d787a3fe28dfc3477b1",
"packages/producer/package.json#script#test:integration": "008589f37d026877595a13501cbb66abd5cbe50e21fdcac6904f45466c3f475d",