Skip to content
Merged
132 changes: 132 additions & 0 deletions packages/core/src/runtime/entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
import type { RuntimeTimelineLike } from "./types";

function pausedTimeline(duration: number): RuntimeTimelineLike {
let time = 0;
return {
play: () => {},
pause: () => {},
seek: (t?: number) => (t === undefined ? time : (time = t)),
totalTime: (t?: number) => (t === undefined ? time : (time = t)),
time: () => time,
duration: () => duration,
add: () => {},
paused: () => true,
timeScale: () => {},
set: () => {},
getChildren: () => [],
};
}

function timed<K extends keyof HTMLElementTagNameMap>(
parent: Element,
tag: K,
start: string,
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
el.className = "clip";
el.setAttribute("data-start", start);
el.setAttribute("data-duration", "2");
el.setAttribute("data-track-index", "1");
parent.appendChild(el);
return el;
}

function mountRoot(): HTMLElement {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: pausedTimeline(10) };
return root;
}

async function evaluateRuntime(): Promise<void> {
vi.resetModules();
await import("./entry");
}

const visibility = (...els: HTMLElement[]) => els.map((el) => getComputedStyle(el).visibility);

describe("runtime entry", () => {
afterEach(() => {
window.__hfRuntimeTeardown?.();
document.head.innerHTML = "";
document.body.innerHTML = "";
window.__timelines = {};
delete window.__player;
delete window.__playerReady;
delete window.__renderReady;
delete window.__hfTimelinesBuilding;
const win = window as {
__hyperframeRuntimeBootstrapped?: boolean;
__hfFirstPassHidden?: boolean;
};
delete win.__hyperframeRuntimeBootstrapped;
delete win.__hfFirstPassHidden;
delete (document as { readyState?: unknown }).readyState;
});

it("paints no timed clip, from script evaluation until the first visibility pass decides it", async () => {
const root = mountRoot();
const current = timed(root, "div", "0");
const later = timed(root, "div", "5");
const poster = timed(root, "img", "0");
// A composition script may write visibility inline before the runtime runs.
later.style.visibility = "visible";
// Readiness, which runs the first pass, waits while GSAP batches timelines.
window.__hfTimelinesBuilding = true;
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });

await evaluateRuntime();
expect(window.__player).toBeUndefined();
expect(visibility(current, later)).toEqual(["hidden", "hidden"]);

delete (document as { readyState?: unknown }).readyState;
document.dispatchEvent(new Event("DOMContentLoaded"));
expect(window.__renderReady).toBe(false);
expect(visibility(current, later, poster)).toEqual(["hidden", "hidden", "visible"]);

window.__hfTimelinesBuilding = false;
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
expect(window.__renderReady).toBe(true);
expect(visibility(current, later, poster)).toEqual(["visible", "hidden", "visible"]);
});

it("leaves nothing hidden when the runtime is evaluated a second time", async () => {
const root = mountRoot();
const current = timed(root, "div", "0");

await evaluateRuntime();
await evaluateRuntime();
// Paused and never sought: no later pass would lift a rule the second copy added.
expect(visibility(root, current)).toEqual(["visible", "visible"]);
expect(document.querySelectorAll("style[data-hf-first-pass-hide]")).toHaveLength(0);
});

it("grades media inside a clip once the first pass shows the clip, with no seek", async () => {
const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
const scene = timed(mountRoot(), "div", "0");
// Untimed: the video inherits the scene's window, so no pass writes its visibility.
const video = document.createElement("video");
video.setAttribute(
HF_COLOR_GRADING_ATTR,
serializeHfColorGrading({ adjust: { exposure: 0.5 } }),
);
Object.defineProperty(video, "readyState", { value: HTMLMediaElement.HAVE_CURRENT_DATA });
Object.defineProperty(video, "videoWidth", { value: 640 });
Object.defineProperty(video, "videoHeight", { value: 360 });
scene.appendChild(video);

await evaluateRuntime();

expect(window.__renderReady).toBe(true);
expect(getContext.mock.calls.some(([type]) => String(type).startsWith("webgl"))).toBe(true);
getContext.mockRestore();
});
});
3 changes: 3 additions & 0 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { initSandboxRuntimeModular } from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { hideTimedClipsUntilFirstPass } from "./timedClipHide";
import { fitTextFontSize } from "../text/fitTextFontSize";
import { pretext } from "../text/pretext";
import { assetUrl } from "./assetUrl";
Expand Down Expand Up @@ -28,6 +29,8 @@ type HyperframeWindow = Window & {
// at script evaluation time, while the document is still parsing.
installAuthoredOpacityCapture();

hideTimedClipsUntilFirstPass();

// Expose runtime helpers immediately so composition scripts can use them
// before DOMContentLoaded (font sizing runs during script evaluation, and
// getVariables is read by composition setup before the timeline is built).
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge";
import { isInClipWindow } from "./clipWindow";
import { revealTimedClipsAfterFirstPass } from "./timedClipHide";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css";
Expand Down Expand Up @@ -2452,6 +2453,7 @@ export function initSandboxRuntimeModular(): void {
0,
timingRevision,
);
let decidedTimedClip = false;
for (const rawNode of visibilityNodes) {
if (!isHtmlElement(rawNode)) continue;

Expand Down Expand Up @@ -2516,6 +2518,7 @@ export function initSandboxRuntimeModular(): void {
}
}
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
if (!isMediaElement(rawNode) && !isImageElement(rawNode)) decidedTimedClip = true;
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
Expand All @@ -2529,6 +2532,7 @@ export function initSandboxRuntimeModular(): void {
timedClipDisplayNoneApplied.add(rawNode);
}
}
if (decidedTimedClip && revealTimedClipsAfterFirstPass()) colorGradingRuntime?.refresh();
// Only when a `data-hidden` mutation actually moved something: the skips
// this reschedule exists to re-run are what change the active set, so
// firing it otherwise was an audible stop-and-restart across the whole mix
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/runtime/timedClipHide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Hides timed non-media clips from script evaluation until the first visibility pass decides them;
// before it, a paused page painted every clip at once. Media is left to init's media pass.
// The rule and its flag live on the page, so every runtime copy shares them.
const HIDE_ATTR = "data-hf-first-pass-hide";
const HIDE_UNTIL_FIRST_PASS =
"[data-start]:not(video, audio, img) { visibility: hidden !important; }";

type FirstPassWindow = Window & {
__hfFirstPassHidden?: boolean;
__hyperframeRuntimeBootstrapped?: boolean;
};

export function hideTimedClipsUntilFirstPass(): void {
if (typeof document === "undefined") return;
const win = window as FirstPassWindow;
// A runtime that already initialised may never run another pass to lift a new rule.
if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return;
const parent = document.head ?? document.documentElement;
if (!parent) return;
const style = document.createElement("style");
style.setAttribute(HIDE_ATTR, "");
style.textContent = HIDE_UNTIL_FIRST_PASS;
parent.appendChild(style);
win.__hfFirstPassHidden = true;
}

/** True when this call lifted the rule; callers then re-register what skipped hidden elements (grading). */
export function revealTimedClipsAfterFirstPass(): boolean {
const win = window as FirstPassWindow;
if (!win.__hfFirstPassHidden) return false;
win.__hfFirstPassHidden = false;
for (const style of document.querySelectorAll(`style[${HIDE_ATTR}]`)) style.remove();
return true;
}
20 changes: 20 additions & 0 deletions packages/shader-transitions/src/capture.clone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { forceSceneVisibleInClone } from "./capture.js";

describe("forceSceneVisibleInClone", () => {
it("shows the scene and its timed clips while the runtime's first-pass hide rule is up", () => {
document.head.innerHTML =
"<style>[data-start]:not(video, audio, img) { visibility: hidden !important; }</style>";
document.body.innerHTML = '<div id="scene" data-start="0"><p data-start="0">Title</p></div>';
const scene = document.getElementById("scene") as HTMLElement;

forceSceneVisibleInClone(scene, document);

const title = scene.querySelector("p") as HTMLElement;
expect([scene, title].map((el) => getComputedStyle(el).visibility)).toEqual([
"visible",
"visible",
]);
});
});
6 changes: 3 additions & 3 deletions packages/shader-transitions/src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ export interface CaptureSceneOptions {
scale?: number;
}

function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
export function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
if (!source.id) return;
const clone = cloneDoc.getElementById(source.id);
if (!(clone instanceof HTMLElement)) return;

clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.style.setProperty("visibility", "visible", "important");
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
el.style.visibility = "visible";
el.style.setProperty("visibility", "visible", "important");
});
}

Expand Down
Loading