mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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)]!);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user