Skip to content

Commit f888db9

Browse files
fix(core): paint no timed clip until the runtime decides its visibility (#4460)
* fix(core): paint no timed clip until the runtime decides its visibility Until its first visibility pass, a paused page showed every timed clip at once: an embed without a loader flashed a later scene before the first, and a player's hidden iframe still decoded every scene's images. The runtime now hides timed non-media clips from the moment its script runs, and the first pass that decides them removes that rule, so each clip is shown only by the pass. * refactor(core): name media with the realm-safe helper in the first-pass check * fix(core): share the first-pass hide across runtime copies and shader captures A second evaluation of the runtime (a sub-composition carrying its own runtime tag, or a page that includes it twice) added a hide rule nothing removed, so the film stayed invisible. The rule and its flag now live on the page: a copy that finds either, or finds the runtime already initialised, adds none, and the first pass removes every copy. HyperShader's capture clone forces its scene visible with !important, so a transition captured before the first pass still shows the scene's clips. The runtime test now also pins evaluation-time hiding, inline visibility, media, and two copies. * fix(core): grade media inside a clip once the first pass shows it Color grading registers only media it can see. Media with no timing of its own inside a timed clip inherited the first-pass hide, so grading skipped it, and lifting the rule is not a body mutation, so nothing registered it again: the image or video stayed ungraded. The pass now refreshes grading when it lifts the rule. The second-copy test now asserts on a paused page, before any seek. * chore(core): keep the comment share of the first-pass hide files at or under their base The comment ratchet counts one added comment line each in entry.ts, init.ts and capture.ts, and a new file above its package's share. The three lines are gone, and the one note that has to stay (why the hide runs at script evaluation) now lives in the new module's shorter header.
1 parent 2623b66 commit f888db9

6 files changed

Lines changed: 196 additions & 3 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// fallow-ignore-file code-duplication
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
4+
import type { RuntimeTimelineLike } from "./types";
5+
6+
function pausedTimeline(duration: number): RuntimeTimelineLike {
7+
let time = 0;
8+
return {
9+
play: () => {},
10+
pause: () => {},
11+
seek: (t?: number) => (t === undefined ? time : (time = t)),
12+
totalTime: (t?: number) => (t === undefined ? time : (time = t)),
13+
time: () => time,
14+
duration: () => duration,
15+
add: () => {},
16+
paused: () => true,
17+
timeScale: () => {},
18+
set: () => {},
19+
getChildren: () => [],
20+
};
21+
}
22+
23+
function timed<K extends keyof HTMLElementTagNameMap>(
24+
parent: Element,
25+
tag: K,
26+
start: string,
27+
): HTMLElementTagNameMap[K] {
28+
const el = document.createElement(tag);
29+
el.className = "clip";
30+
el.setAttribute("data-start", start);
31+
el.setAttribute("data-duration", "2");
32+
el.setAttribute("data-track-index", "1");
33+
parent.appendChild(el);
34+
return el;
35+
}
36+
37+
function mountRoot(): HTMLElement {
38+
const root = document.createElement("div");
39+
root.setAttribute("data-composition-id", "main");
40+
root.setAttribute("data-root", "true");
41+
root.setAttribute("data-start", "0");
42+
root.setAttribute("data-width", "1920");
43+
root.setAttribute("data-height", "1080");
44+
document.body.appendChild(root);
45+
window.__timelines = { main: pausedTimeline(10) };
46+
return root;
47+
}
48+
49+
async function evaluateRuntime(): Promise<void> {
50+
vi.resetModules();
51+
await import("./entry");
52+
}
53+
54+
const visibility = (...els: HTMLElement[]) => els.map((el) => getComputedStyle(el).visibility);
55+
56+
describe("runtime entry", () => {
57+
afterEach(() => {
58+
window.__hfRuntimeTeardown?.();
59+
document.head.innerHTML = "";
60+
document.body.innerHTML = "";
61+
window.__timelines = {};
62+
delete window.__player;
63+
delete window.__playerReady;
64+
delete window.__renderReady;
65+
delete window.__hfTimelinesBuilding;
66+
const win = window as {
67+
__hyperframeRuntimeBootstrapped?: boolean;
68+
__hfFirstPassHidden?: boolean;
69+
};
70+
delete win.__hyperframeRuntimeBootstrapped;
71+
delete win.__hfFirstPassHidden;
72+
delete (document as { readyState?: unknown }).readyState;
73+
});
74+
75+
it("paints no timed clip, from script evaluation until the first visibility pass decides it", async () => {
76+
const root = mountRoot();
77+
const current = timed(root, "div", "0");
78+
const later = timed(root, "div", "5");
79+
const poster = timed(root, "img", "0");
80+
// A composition script may write visibility inline before the runtime runs.
81+
later.style.visibility = "visible";
82+
// Readiness, which runs the first pass, waits while GSAP batches timelines.
83+
window.__hfTimelinesBuilding = true;
84+
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
85+
86+
await evaluateRuntime();
87+
expect(window.__player).toBeUndefined();
88+
expect(visibility(current, later)).toEqual(["hidden", "hidden"]);
89+
90+
delete (document as { readyState?: unknown }).readyState;
91+
document.dispatchEvent(new Event("DOMContentLoaded"));
92+
expect(window.__renderReady).toBe(false);
93+
expect(visibility(current, later, poster)).toEqual(["hidden", "hidden", "visible"]);
94+
95+
window.__hfTimelinesBuilding = false;
96+
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
97+
expect(window.__renderReady).toBe(true);
98+
expect(visibility(current, later, poster)).toEqual(["visible", "hidden", "visible"]);
99+
});
100+
101+
it("leaves nothing hidden when the runtime is evaluated a second time", async () => {
102+
const root = mountRoot();
103+
const current = timed(root, "div", "0");
104+
105+
await evaluateRuntime();
106+
await evaluateRuntime();
107+
// Paused and never sought: no later pass would lift a rule the second copy added.
108+
expect(visibility(root, current)).toEqual(["visible", "visible"]);
109+
expect(document.querySelectorAll("style[data-hf-first-pass-hide]")).toHaveLength(0);
110+
});
111+
112+
it("grades media inside a clip once the first pass shows the clip, with no seek", async () => {
113+
const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
114+
const scene = timed(mountRoot(), "div", "0");
115+
// Untimed: the video inherits the scene's window, so no pass writes its visibility.
116+
const video = document.createElement("video");
117+
video.setAttribute(
118+
HF_COLOR_GRADING_ATTR,
119+
serializeHfColorGrading({ adjust: { exposure: 0.5 } }),
120+
);
121+
Object.defineProperty(video, "readyState", { value: HTMLMediaElement.HAVE_CURRENT_DATA });
122+
Object.defineProperty(video, "videoWidth", { value: 640 });
123+
Object.defineProperty(video, "videoHeight", { value: 360 });
124+
scene.appendChild(video);
125+
126+
await evaluateRuntime();
127+
128+
expect(window.__renderReady).toBe(true);
129+
expect(getContext.mock.calls.some(([type]) => String(type).startsWith("webgl"))).toBe(true);
130+
getContext.mockRestore();
131+
});
132+
});

‎packages/core/src/runtime/entry.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { initSandboxRuntimeModular } from "./init";
22
import { installAuthoredOpacityCapture } from "./colorGrading";
3+
import { hideTimedClipsUntilFirstPass } from "./timedClipHide";
34
import { fitTextFontSize } from "../text/fitTextFontSize";
45
import { pretext } from "../text/pretext";
56
import { assetUrl } from "./assetUrl";
@@ -28,6 +29,8 @@ type HyperframeWindow = Window & {
2829
// at script evaluation time, while the document is still parsing.
2930
installAuthoredOpacityCapture();
3031

32+
hideTimedClipsUntilFirstPass();
33+
3134
// Expose runtime helpers immediately so composition scripts can use them
3235
// before DOMContentLoaded (font sizing runs during script evaluation, and
3336
// getVariables is read by composition setup before the timeline is built).

‎packages/core/src/runtime/init.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// fallow-ignore-file code-duplication complexity
22
import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge";
33
import { isInClipWindow } from "./clipWindow";
4+
import { revealTimedClipsAfterFirstPass } from "./timedClipHide";
45
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
56
import { injectCompositionCssVariables } from "./getVariables";
67
import { createCssAdapter } from "./adapters/css";
@@ -2452,6 +2453,7 @@ export function initSandboxRuntimeModular(): void {
24522453
0,
24532454
timingRevision,
24542455
);
2456+
let decidedTimedClip = false;
24552457
for (const rawNode of visibilityNodes) {
24562458
if (!isHtmlElement(rawNode)) continue;
24572459

@@ -2516,6 +2518,7 @@ export function initSandboxRuntimeModular(): void {
25162518
}
25172519
}
25182520
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
2521+
if (!isMediaElement(rawNode) && !isImageElement(rawNode)) decidedTimedClip = true;
25192522
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
25202523
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
25212524
}
@@ -2529,6 +2532,7 @@ export function initSandboxRuntimeModular(): void {
25292532
timedClipDisplayNoneApplied.add(rawNode);
25302533
}
25312534
}
2535+
if (decidedTimedClip && revealTimedClipsAfterFirstPass()) colorGradingRuntime?.refresh();
25322536
// Only when a `data-hidden` mutation actually moved something: the skips
25332537
// this reschedule exists to re-run are what change the active set, so
25342538
// firing it otherwise was an audible stop-and-restart across the whole mix
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Hides timed non-media clips from script evaluation until the first visibility pass decides them;
2+
// before it, a paused page painted every clip at once. Media is left to init's media pass.
3+
// The rule and its flag live on the page, so every runtime copy shares them.
4+
const HIDE_ATTR = "data-hf-first-pass-hide";
5+
const HIDE_UNTIL_FIRST_PASS =
6+
"[data-start]:not(video, audio, img) { visibility: hidden !important; }";
7+
8+
type FirstPassWindow = Window & {
9+
__hfFirstPassHidden?: boolean;
10+
__hyperframeRuntimeBootstrapped?: boolean;
11+
};
12+
13+
export function hideTimedClipsUntilFirstPass(): void {
14+
if (typeof document === "undefined") return;
15+
const win = window as FirstPassWindow;
16+
// A runtime that already initialised may never run another pass to lift a new rule.
17+
if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return;
18+
const parent = document.head ?? document.documentElement;
19+
if (!parent) return;
20+
const style = document.createElement("style");
21+
style.setAttribute(HIDE_ATTR, "");
22+
style.textContent = HIDE_UNTIL_FIRST_PASS;
23+
parent.appendChild(style);
24+
win.__hfFirstPassHidden = true;
25+
}
26+
27+
/** True when this call lifted the rule; callers then re-register what skipped hidden elements (grading). */
28+
export function revealTimedClipsAfterFirstPass(): boolean {
29+
const win = window as FirstPassWindow;
30+
if (!win.__hfFirstPassHidden) return false;
31+
win.__hfFirstPassHidden = false;
32+
for (const style of document.querySelectorAll(`style[${HIDE_ATTR}]`)) style.remove();
33+
return true;
34+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// @vitest-environment jsdom
2+
import { describe, expect, it } from "vitest";
3+
import { forceSceneVisibleInClone } from "./capture.js";
4+
5+
describe("forceSceneVisibleInClone", () => {
6+
it("shows the scene and its timed clips while the runtime's first-pass hide rule is up", () => {
7+
document.head.innerHTML =
8+
"<style>[data-start]:not(video, audio, img) { visibility: hidden !important; }</style>";
9+
document.body.innerHTML = '<div id="scene" data-start="0"><p data-start="0">Title</p></div>';
10+
const scene = document.getElementById("scene") as HTMLElement;
11+
12+
forceSceneVisibleInClone(scene, document);
13+
14+
const title = scene.querySelector("p") as HTMLElement;
15+
expect([scene, title].map((el) => getComputedStyle(el).visibility)).toEqual([
16+
"visible",
17+
"visible",
18+
]);
19+
});
20+
});

‎packages/shader-transitions/src/capture.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ export interface CaptureSceneOptions {
4949
scale?: number;
5050
}
5151

52-
function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
52+
export function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
5353
if (!source.id) return;
5454
const clone = cloneDoc.getElementById(source.id);
5555
if (!(clone instanceof HTMLElement)) return;
5656

5757
clone.style.opacity = "1";
58-
clone.style.visibility = "visible";
58+
clone.style.setProperty("visibility", "visible", "important");
5959
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
60-
el.style.visibility = "visible";
60+
el.style.setProperty("visibility", "visible", "important");
6161
});
6262
}
6363

0 commit comments

Comments
 (0)