diff --git a/packages/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts new file mode 100644 index 0000000000..74cc2e98a0 --- /dev/null +++ b/packages/core/src/runtime/entry.test.ts @@ -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( + 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 { + 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(); + }); +}); diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 779668b0fe..58c86985c0 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -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"; @@ -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). diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 076554b167..2dfef746c7 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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"; @@ -2452,6 +2453,7 @@ export function initSandboxRuntimeModular(): void { 0, timingRevision, ); + let decidedTimedClip = false; for (const rawNode of visibilityNodes) { if (!isHtmlElement(rawNode)) continue; @@ -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); } @@ -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 diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts new file mode 100644 index 0000000000..b590ec57b6 --- /dev/null +++ b/packages/core/src/runtime/timedClipHide.ts @@ -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; +} diff --git a/packages/shader-transitions/src/capture.clone.test.ts b/packages/shader-transitions/src/capture.clone.test.ts new file mode 100644 index 0000000000..c5746cc682 --- /dev/null +++ b/packages/shader-transitions/src/capture.clone.test.ts @@ -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 = + ""; + document.body.innerHTML = '

Title

'; + 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", + ]); + }); +}); diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index 08aa82caff..31c30f2538 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -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("[data-start]").forEach((el) => { - el.style.visibility = "visible"; + el.style.setProperty("visibility", "visible", "important"); }); }