From 0979e32a8dd7cbf951829ce7fb6298528aba5c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 26 Oct 2026 16:48:57 +0000 Subject: [PATCH] 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 --- .../core/src/runtime/startResolver.test.ts | 45 ++++++++++ packages/core/src/runtime/startResolver.ts | 15 ++-- .../producer/scripts/test-classification.mjs | 1 + .../wrappedMountTiming.integration.test.ts | 88 +++++++++++++++++++ scripts/test-reachability.json | 2 +- 5 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 packages/producer/src/services/render/wrappedMountTiming.integration.test.ts diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index 0880ffe99e..1a00dd173a 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -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 = "
".repeat(wrappers); + const close = "
".repeat(wrappers); + document.body.innerHTML = ` +
+
+ ${open}
${close} +
+
`; + 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 = ` +
+
+
+
+
+
+
`; + 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 = ` +
+
+
+
+
`; + 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"; diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index 326930dc0f..c1ef59a2c7 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -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 => diff --git a/packages/producer/scripts/test-classification.mjs b/packages/producer/scripts/test-classification.mjs index f3367c150b..e54fe6fd9a 100644 --- a/packages/producer/scripts/test-classification.mjs +++ b/packages/producer/scripts/test-classification.mjs @@ -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", diff --git a/packages/producer/src/services/render/wrappedMountTiming.integration.test.ts b/packages/producer/src/services/render/wrappedMountTiming.integration.test.ts new file mode 100644 index 0000000000..fa03291a9c --- /dev/null +++ b/packages/producer/src/services/render/wrappedMountTiming.integration.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 = ` + + + + + + + +
+
+
+
+
+
+
+ + + +`; + +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)]!); + }); +}); diff --git a/scripts/test-reachability.json b/scripts/test-reachability.json index f1f039b382..1154e35fd6 100644 --- a/scripts/test-reachability.json +++ b/scripts/test-reachability.json @@ -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",