fix(core): a clip that starts later keeps the place and size its CSS gives it (#4495)

* fix(core): a clip that starts later keeps the place and size its CSS gives it

A timed root clip that had not started when the runtime laid clips out was display:none,
so it measured as auto: it was pinned to the top-left corner, and an empty full-frame clip
got no size. Measure a hidden clip as it will show and never pin, so it lays out like a
clip showing at load.

* perf(core): lay out hidden clips in one pass and pin the !important restore

Lift every hidden clip's display:none at once so a layout pass forces one layout instead of
one per hidden clip; it runs every 20 frames during playback. Add a test that an author's
display:none !important comes back with its priority, and fix comments that described the
removed pin.

* test(core): describe what the applyClipLayout test block covers

* refactor(core): name the display lift instead of commenting it
This commit is contained in:
Miguel Ángel
2026-09-25 18:02:24 +00:00
committed by GitHub
parent fbaa9f9205
commit aadeaf9ddd
2 changed files with 103 additions and 33 deletions
+89 -12
View File
@@ -3902,13 +3902,10 @@ describe("initSandboxRuntimeModular", () => {
expect(window.__player?.getDuration()).toBe(5);
});
// applyClipLayout force-absolutizes authored root-level timed clips so they
// stack as overlays. But in Studio/preview the runtime also stamps `data-start`
// onto ID'd / GSAP-targeted *flow* children (a <header>/<footer> in a column)
// so the design panel can discover them — those must NOT be force-absolutized,
// or the layout collapses (footer shrink-wraps, `space-between` clusters). The
// marker `data-hf-autostamped` distinguishes them; these tests pin both halves.
describe("applyClipLayout: runtime-stamped clips stay in document flow", () => {
// applyClipLayout force-absolutizes authored root-level timed clips, leaves their
// position to CSS, and measures hidden clips as shown. Runtime-stamped flow
// children (`data-hf-autostamped`) stay in flow, or a flex column collapses.
describe("applyClipLayout", () => {
const makeRoot = () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
@@ -3921,8 +3918,8 @@ describe("initSandboxRuntimeModular", () => {
};
// jsdom does no layout, so a static clip can report computed top "auto" or
// "" inconsistently. Pin the values the anchor gate keys on so the assertion
// reflects the real-browser path deterministically.
// "" inconsistently. Pin the values the layout pass reads so the assertion
// is deterministic.
const overrideComputed = (
target: HTMLElement,
overrides: Partial<Record<"position" | "top" | "left" | "bottom" | "right", string>>,
@@ -3946,7 +3943,7 @@ describe("initSandboxRuntimeModular", () => {
}) as typeof window.getComputedStyle);
};
it("force-absolutizes an authored data-start clip (baseline behavior preserved)", () => {
it("force-absolutizes an authored data-start clip and leaves its position to CSS", () => {
const root = makeRoot();
const clip = document.createElement("div");
clip.setAttribute("data-start", "0"); // authored clip, no autostamp marker
@@ -3963,8 +3960,88 @@ describe("initSandboxRuntimeModular", () => {
initSandboxRuntimeModular();
expect(clip.style.position).toBe("absolute");
expect(clip.style.top).toBe("0px");
expect(clip.style.left).toBe("0px");
expect(clip.style.top).toBe("");
expect(clip.style.left).toBe("");
});
// A browser reports "auto" for every box value of a display:none element and pixels once
// it shows. jsdom does no layout, so model that for the clip under test.
const modelBrowserLayout = (target: HTMLElement, shown: { width: string; height: string }) => {
const real = window.getComputedStyle.bind(window);
vi.spyOn(window, "getComputedStyle").mockImplementation(((
el: Element,
pseudo?: string | null,
) => {
const style = real(el as Element, pseudo ?? undefined);
if (el !== target) return style;
const box = (): Record<string, string> => {
if (target.style.display === "none") {
return { width: "auto", height: "auto", top: "auto", left: "auto" };
}
return { ...shown, top: "499px", left: "784px" };
};
return new Proxy(style, {
get(t, prop) {
if (prop === "position") return target.style.position || "static";
if (prop === "bottom" || prop === "right") return "auto";
if (typeof prop === "string" && prop in box()) return box()[prop];
const value = Reflect.get(t, prop);
return typeof value === "function" ? value.bind(t) : value;
},
}) as CSSStyleDeclaration;
}) as typeof window.getComputedStyle);
};
it("keeps a clip that starts later where its CSS puts it (flex-centred title)", () => {
const root = makeRoot();
root.style.cssText = "display:flex;align-items:center;justify-content:center";
const title = document.createElement("h1");
title.setAttribute("data-start", "0.88");
title.setAttribute("data-duration", "5");
title.textContent = "Agent one";
root.appendChild(title);
modelBrowserLayout(title, { width: "352px", height: "82px" });
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(title.style.display).toBe("none");
expect(title.style.position).toBe("absolute");
expect(title.style.top).toBe("");
expect(title.style.left).toBe("");
});
it("sizes an empty clip that starts later like one showing at load", () => {
const root = makeRoot();
const card = document.createElement("div");
card.setAttribute("data-start", "1");
card.setAttribute("data-duration", "5");
root.appendChild(card);
modelBrowserLayout(card, { width: "0px", height: "0px" });
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(card.style.display).toBe("none");
expect(card.style.width).toBe("100%");
expect(card.style.height).toBe("100%");
});
it("puts back an author's display:none !important after measuring the clip", () => {
const root = makeRoot();
const card = document.createElement("div");
card.setAttribute("data-start", "0");
card.setAttribute("data-duration", "5");
card.style.setProperty("display", "none", "important");
root.appendChild(card);
modelBrowserLayout(card, { width: "0px", height: "0px" });
window.__timelines = { main: createMockTimeline(10) };
initSandboxRuntimeModular();
expect(card.style.width).toBe("100%");
expect(card.style.getPropertyValue("display")).toBe("none");
expect(card.style.getPropertyPriority("display")).toBe("important");
});
it("leaves a runtime-stamped flow child untouched so the layout is preserved", () => {
+14 -21
View File
@@ -629,11 +629,10 @@ export function initSandboxRuntimeModular(): void {
const rootHeight = parseDimensionPx(rootEl.getAttribute("data-height"));
if (rootWidth) rootEl.style.width = rootWidth;
if (rootHeight) rootEl.style.height = rootHeight;
const children = Array.from(rootEl.children) as HTMLElement[];
for (const el of children) {
const clips = (Array.from(rootEl.children) as HTMLElement[]).filter((el) => {
const tag = el.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
if (!el.hasAttribute("data-start")) continue;
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false;
if (!el.hasAttribute("data-start")) return false;
// Runtime-stamped clips are NOT authored overlay clips. In Studio/preview
// the runtime stamps `data-start` onto ID'd or GSAP-targeted flow children
// (a <header>/<footer> in a flex column) so the design panel can discover
@@ -642,7 +641,14 @@ export function initSandboxRuntimeModular(): void {
// `justify-content: space-between` clusters in the top-left. Leave them in
// flow so the preview matches the rendered video, which never stamps
// (production renders run as the top-level page, not in an iframe).
if (el.hasAttribute("data-hf-autostamped")) continue;
return !el.hasAttribute("data-hf-autostamped");
});
const displayNoneLiftedToMeasureShown = clips
.filter((el) => el.style.getPropertyValue("display") === "none")
.map((el) => ({ el, priority: el.style.getPropertyPriority("display") }));
for (const { el } of displayNoneLiftedToMeasureShown) el.style.removeProperty("display");
for (const el of clips) {
const tag = el.tagName.toLowerCase();
const hasLegacyAnchoredDefaults =
(el.style.top === "0px" || el.style.top === "0") &&
(el.style.left === "0px" || el.style.left === "0") &&
@@ -687,22 +693,6 @@ export function initSandboxRuntimeModular(): void {
if (shouldForceAbsolute) {
el.style.position = "absolute";
}
const hasExplicitVerticalAnchor =
Boolean(el.style.top) ||
Boolean(el.style.bottom) ||
computed.top !== "auto" ||
computed.bottom !== "auto";
if (!hasExplicitVerticalAnchor) {
el.style.top = "0";
}
const hasExplicitHorizontalAnchor =
Boolean(el.style.left) ||
Boolean(el.style.right) ||
computed.left !== "auto" ||
computed.right !== "auto";
if (!hasExplicitHorizontalAnchor) {
el.style.left = "0";
}
if (tag !== "audio") {
const forcedWidth = parseDimensionPx(el.getAttribute("data-width"));
const forcedHeight = parseDimensionPx(el.getAttribute("data-height"));
@@ -724,6 +714,9 @@ export function initSandboxRuntimeModular(): void {
}
}
}
for (const { el, priority } of displayNoneLiftedToMeasureShown) {
el.style.setProperty("display", "none", priority);
}
};
const createTimingResolver = (includeAuthoredTimingAttrs: boolean) =>