From f9245bfef74a42a6a3b44d460d0b8e9becc61a24 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 16 Oct 2026 13:32:25 -0700 Subject: [PATCH 1/2] perf(core): show the first frame without waiting on far scenes' fonts --- .../src/compiler/inlineSubCompositions.ts | 9 ++- packages/core/src/runtime/afterFonts.ts | 25 ++++---- packages/core/src/runtime/entry.ts | 13 +++-- packages/core/src/runtime/heldScenes.test.ts | 58 +++++++++++++++---- packages/core/src/runtime/heldScenes.ts | 31 ++++++++-- packages/core/src/runtime/init.ts | 1 + packages/core/src/sceneParts.ts | 2 + 7 files changed, 104 insertions(+), 35 deletions(-) diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 17eeca68c18..a0f43e8e775 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -45,7 +45,12 @@ import { planCompositionAssembly, extractedCompositionAssets, } from "./compositionAssembly"; -import { SCENE_EAGER_ATTR, SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts"; +import { + REACHED_BY_ROOT_REASON, + SCENE_EAGER_ATTR, + SCENE_NO_SWAP_ATTR, + SCENE_PART_ATTR, +} from "../sceneParts"; // Anything a scene script can leave running, pending or registered outside its timeline, or that // throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse. @@ -164,7 +169,7 @@ export function refuseScenesReachedByRootScripts(document: Document, rootScripts for (const host of reached) for (const attr of [SCENE_NO_SWAP_ATTR, SCENE_EAGER_ATTR]) if (!host.hasAttribute(attr)) - host.setAttribute(attr, `a script outside the scene selects ${literal}`); + host.setAttribute(attr, `${REACHED_BY_ROOT_REASON} ${literal}`); } } diff --git a/packages/core/src/runtime/afterFonts.ts b/packages/core/src/runtime/afterFonts.ts index 389b75d91e8..59be43836f2 100644 --- a/packages/core/src/runtime/afterFonts.ts +++ b/packages/core/src/runtime/afterFonts.ts @@ -6,26 +6,28 @@ export const FONT_WAIT_TIMEOUT_MS = 5000; (window as unknown as Record)[AFTER_FONTS_CLAIM] = true; -/** Resolves once the page's web fonts are ready or the timeout passes, reporting faces still loading. */ -export async function waitForFonts(): Promise { +export const fontFamilyKey = (family: string) => family.replace(/["']/g, "").trim().toLowerCase(); + +/** Resolves once the web fonts (of `families`, if given) are ready or time out, reporting faces still loading. */ +export async function waitForFonts(families?: ReadonlySet | null): Promise { const fonts = document.fonts; if (!fonts) return; // Layout starts loading the faces laid-out text uses; with none loading, ready resolves at once. void document.body?.offsetHeight; + const counts = (face: FontFace) => !families || families.has(fontFamilyKey(face.family)); + const loading = () => + Array.from(fonts).filter((face) => face.status === "loading" && counts(face)); + const ready = families + ? Promise.all(loading().map((face) => face.loaded.catch(() => face))) + : fonts.ready; let timer: ReturnType | undefined; const timedOut = await Promise.race([ - fonts.ready.then(() => false), + ready.then(() => false), new Promise((resolve) => (timer = setTimeout(resolve, FONT_WAIT_TIMEOUT_MS, true))), ]); clearTimeout(timer); if (!timedOut) return; - const loadingFamilies = [ - ...new Set( - Array.from(fonts) - .filter((face) => face.status === "loading") - .map((face) => face.family), - ), - ]; + const loadingFamilies = [...new Set(loading().map((face) => face.family))]; console.warn( `[hyperframes] fonts still loading after ${FONT_WAIT_TIMEOUT_MS} ms, composition scripts run without them: ${loadingFamilies.join(", ")}`, ); @@ -126,8 +128,9 @@ function afterQueuedModules(): Promise { export async function runScriptsAfterFonts( scripts: readonly Element[], afterRun?: () => void, + families?: ReadonlySet | null, ): Promise { - await waitForFonts(); + await waitForFonts(families); const isLate = (el: Element) => typeAfterFonts(el) === "module" || el.matches(DEFERRED_FILE); const held: HeldListener[] = []; const release = [document, window].map((target) => holdPassedLoadEvents(target, held)); diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 7347e2175ad..12f6afda498 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -7,7 +7,7 @@ import { } from "./init"; import { installAuthoredOpacityCapture } from "./colorGrading"; import { deferMediaUntilDue } from "./preloadMedia"; -import { holdFarSceneScripts } from "./heldScenes"; +import { bootFontFamilies, holdFarSceneScripts } from "./heldScenes"; import { hideTimedClipsUntilFirstPass } from "./timedClipHide"; import { fitTextFontSize } from "../text/fitTextFontSize"; import { pretext } from "../text/pretext"; @@ -82,10 +82,13 @@ function startAfterCompositionScripts(): void { ); if (deferred.length === 0) bootstrapHyperframeRuntime(); else { - void runScriptsAfterFonts(deferred, bootstrapHyperframeRuntime).catch((err: unknown) => { - recordStartupError(err); - reportError(err); - }); + const families = bootFontFamilies(); + void runScriptsAfterFonts(deferred, bootstrapHyperframeRuntime, families).catch( + (err: unknown) => { + recordStartupError(err); + reportError(err); + }, + ); } } catch (err) { recordStartupError(err); diff --git a/packages/core/src/runtime/heldScenes.test.ts b/packages/core/src/runtime/heldScenes.test.ts index c9b7f723c4b..99b482aaa9a 100644 --- a/packages/core/src/runtime/heldScenes.test.ts +++ b/packages/core/src/runtime/heldScenes.test.ts @@ -27,10 +27,33 @@ const sceneScript = (id: string) => `document.body.dataset.ran = (document.body.dataset.ran || "") + "${id} ";` + `document.dispatchEvent(new CustomEvent("scene-ran", { detail: "${id}" }));`; -const host = (id: string, start: number, attrs = "") => - `

${id}

`; +const host = (id: string, start: number, attrs = "", font = "serif") => + `

${id}

`; -async function boot(preview: boolean) { +// A face per family, each loading until the test settles it. +function serveFonts(...families: string[]) { + const settle: Record void> = {}; + const faces = families.map((family) => { + const face = { family, status: "loading" as FontFaceLoadStatus, loaded: Promise.resolve() }; + face.loaded = new Promise((done) => { + settle[family] = () => { + face.status = "loaded"; + done(); + }; + }); + return face; + }); + Object.defineProperty(document, "fonts", { + configurable: true, + value: { + ready: Promise.all(faces.map((face) => face.loaded)), + [Symbol.iterator]: () => faces[Symbol.iterator](), + }, + }); + return settle; +} + +async function boot(preview: boolean, fonts = { near: "serif", tied: "serif" }) { (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS ??= {}; globalThis.CSS.escape ??= (v: string) => v; if (preview) @@ -39,11 +62,12 @@ async function boot(preview: boolean) { ); document.body.innerHTML = `
` + - host("near", 0) + + host("near", 0, "", fonts.near) + host("far", 30) + - host("tied", 40, ' data-hf-scene-eager="its script uses documentElement"') + + host("tied", 40, ' data-hf-scene-eager="its script uses documentElement"', fonts.tied) + + host("reached", 50, ' data-hf-scene-eager="a script outside the scene selects .x"') + `
` + - ["near", "far", "tied"].map(sceneScript).join(""); + ["near", "far", "tied", "reached"].map(sceneScript).join(""); const added: Array<[unknown, number | undefined]> = []; window.__timelines = { main: timeline(60, added) }; document.addEventListener("scene-ran", (event) => { @@ -67,6 +91,7 @@ describe("scene scripts held until the playhead nears", () => { delete document.body.dataset.saw; window.__timelines = {}; delete window.__player; + delete (document as { fonts?: unknown }).fonts; delete (window as { __hyperframeRuntimeBootstrapped?: boolean }) .__hyperframeRuntimeBootstrapped; }); @@ -94,20 +119,21 @@ describe("scene scripts held until the playhead nears", () => { it("runs a far scene's scripts once a seek comes within the lead, and nests it at its start", async () => { const added = await boot(true); - expect(ran()).toBe("near tied "); + await window.__hfWaitForSeekCompletion?.(); + expect(ran()).toBe("near reached tied "); window.__player!.seek(12); await window.__hfWaitForSeekCompletion?.(); - expect(ran()).toBe("near tied "); + expect(ran()).toBe("near reached tied "); window.__player!.seek(25); await window.__hfWaitForSeekCompletion?.(); - expect(ran()).toBe("near tied far "); + expect(ran()).toBe("near reached tied far "); expect(added).toContainEqual([window.__timelines!.far, 30]); window.__player!.seek(31); await window.__hfWaitForSeekCompletion?.(); - expect(ran()).toBe("near tied far "); + expect(ran()).toBe("near reached tied far "); }); it("runs a far scene the playback reaches without pausing the clock", async () => { @@ -127,6 +153,16 @@ describe("scene scripts held until the playhead nears", () => { it("runs every scene's scripts at boot outside a preview, as a capture does", async () => { await boot(false); - expect(ran()).toBe("near far tied "); + expect(ran()).toBe("near far tied reached "); + }); + + it("boots once the fonts of the near scenes load; a far eager scene runs once every font has", async () => { + const settle = serveFonts("Near", "Far"); + settle.Near!(); + await boot(true, { near: '"Near", serif', tied: "Far" }); + expect(ran()).toBe("near reached "); + + settle.Far!(); + await vi.waitFor(() => expect(ran()).toBe("near reached tied ")); }); }); diff --git a/packages/core/src/runtime/heldScenes.ts b/packages/core/src/runtime/heldScenes.ts index 64a967bf7d9..eb6a7c9a950 100644 --- a/packages/core/src/runtime/heldScenes.ts +++ b/packages/core/src/runtime/heldScenes.ts @@ -1,4 +1,5 @@ -import { SCENE_EAGER_ATTR, SCENE_PART_ATTR } from "../sceneParts"; +import { REACHED_BY_ROOT_REASON, SCENE_EAGER_ATTR, SCENE_PART_ATTR } from "../sceneParts"; +import { fontFamilyKey } from "./afterFonts"; import { parseStrictFiniteTimingNumber } from "./playbackRate"; import { skipsHiddenImages } from "./timedClipHide"; @@ -19,16 +20,18 @@ const timing = (host: Element, name: string) => function holdable(host: Element | undefined): HeldScene | null { // Swaps and boot give captions their edited words; a mount would not. - if (!host || host.hasAttribute(SCENE_EAGER_ATTR) || host.querySelector(".caption-group")) - return null; + if (!host || host.querySelector(".caption-group")) return null; const start = timing(host, "data-start"); const duration = timing(host, "data-duration"); if (start == null || duration == null || start <= SCENE_LEAD_SECONDS) return null; - return { host, start, end: start + duration, scripts: [] }; + const eager = host.getAttribute(SCENE_EAGER_ATTR); + if (eager == null) return { host, start, end: start + duration, scripts: [] }; + if (eager.startsWith(REACHED_BY_ROOT_REASON)) return null; + return { host, start: -Infinity, end: Infinity, scripts: [] }; } -/** Preview only: the boot scripts, less those of scenes that start past the lead, which wait for - * the playhead to come near. A known library a scene loads still runs at boot. */ +/** Preview only: the boot scripts, less far scenes' (run near the playhead) and eager scenes' (run + * right after boot, in order). A known library a scene loads still runs at boot. */ export function holdFarSceneScripts(scripts: Element[]): Element[] { held.clear(); if (!skipsHiddenImages()) return scripts; @@ -57,3 +60,19 @@ export function takeHeldScenesNear(t: number): HeldScene[] { } return due; } + +/** The font families of the text the boot scripts' scenes show, or null when no scene waits. */ +export function bootFontFamilies(): Set | null { + if (held.size === 0 || !document.body) return null; + const waiting = new Set([...held.values()].map((scene) => scene.host)); + const families = new Set(); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => + waiting.has(node as Element) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, + }); + for (let el = walker.nextNode() as Element | null; el; el = walker.nextNode() as Element | null) + if ([...el.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim())) + for (const family of getComputedStyle(el).fontFamily.split(",")) + families.add(fontFamilyKey(family)); + return families; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d9134a8461b..cc2dd498e9c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4257,6 +4257,7 @@ export function initSandboxRuntimeModular(): void { forceDispatchSeekEvent(t); }; window.__hfWaitForSeekCompletion = waitForSeekCompletion; + mountHeldScenesNear(state.currentTime); runtimeCleanupCallbacks.push(() => { if (window.__hfWaitForSeekCompletion === waitForSeekCompletion) { delete window.__hfWaitForSeekCompletion; diff --git a/packages/core/src/sceneParts.ts b/packages/core/src/sceneParts.ts index ed5624dcc88..31c35959ca1 100644 --- a/packages/core/src/sceneParts.ts +++ b/packages/core/src/sceneParts.ts @@ -7,6 +7,8 @@ export const SCENE_NO_SWAP_ATTR = "data-hf-scene-no-swap"; export const SCENE_EAGER_ATTR = "data-hf-scene-eager"; +export const REACHED_BY_ROOT_REASON = "a script outside the scene selects"; + /** Hashes of one preview document: everything outside the scene parts, and each scene's parts. */ export interface SceneParts { shared: string; From 525d817b0d462ba2969900c08a03df2a56d32af0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 17 Oct 2026 03:33:44 -0700 Subject: [PATCH 2/2] perf(core): a scene another scene reads runs at boot, before its readers --- packages/core/src/compiler/inlineSubCompositions.ts | 3 ++- packages/core/src/runtime/heldScenes.ts | 9 +++++++-- packages/core/src/sceneParts.ts | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index a0f43e8e775..80d1e2580ac 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -47,6 +47,7 @@ import { } from "./compositionAssembly"; import { REACHED_BY_ROOT_REASON, + READ_BY_SCENE_REASON, SCENE_EAGER_ATTR, SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, @@ -845,7 +846,7 @@ export function keepScenesReadAtBootEager( for (const provider of readsOf(reader)) { const host = sceneHosts.get(provider); if (!host || host.hasAttribute(SCENE_EAGER_ATTR)) continue; - host.setAttribute(SCENE_EAGER_ATTR, `${reader} reads it`); + host.setAttribute(SCENE_EAGER_ATTR, `${reader} ${READ_BY_SCENE_REASON}`); eager.push(provider); } } diff --git a/packages/core/src/runtime/heldScenes.ts b/packages/core/src/runtime/heldScenes.ts index eb6a7c9a950..68510ff87aa 100644 --- a/packages/core/src/runtime/heldScenes.ts +++ b/packages/core/src/runtime/heldScenes.ts @@ -1,4 +1,9 @@ -import { REACHED_BY_ROOT_REASON, SCENE_EAGER_ATTR, SCENE_PART_ATTR } from "../sceneParts"; +import { + READ_BY_SCENE_REASON, + REACHED_BY_ROOT_REASON, + SCENE_EAGER_ATTR, + SCENE_PART_ATTR, +} from "../sceneParts"; import { fontFamilyKey } from "./afterFonts"; import { parseStrictFiniteTimingNumber } from "./playbackRate"; import { skipsHiddenImages } from "./timedClipHide"; @@ -26,7 +31,7 @@ function holdable(host: Element | undefined): HeldScene | null { if (start == null || duration == null || start <= SCENE_LEAD_SECONDS) return null; const eager = host.getAttribute(SCENE_EAGER_ATTR); if (eager == null) return { host, start, end: start + duration, scripts: [] }; - if (eager.startsWith(REACHED_BY_ROOT_REASON)) return null; + if (eager.startsWith(REACHED_BY_ROOT_REASON) || eager.endsWith(READ_BY_SCENE_REASON)) return null; return { host, start: -Infinity, end: Infinity, scripts: [] }; } diff --git a/packages/core/src/sceneParts.ts b/packages/core/src/sceneParts.ts index 31c35959ca1..3d44f179b07 100644 --- a/packages/core/src/sceneParts.ts +++ b/packages/core/src/sceneParts.ts @@ -9,6 +9,8 @@ export const SCENE_EAGER_ATTR = "data-hf-scene-eager"; export const REACHED_BY_ROOT_REASON = "a script outside the scene selects"; +export const READ_BY_SCENE_REASON = "reads it"; + /** Hashes of one preview document: everything outside the scene parts, and each scene's parts. */ export interface SceneParts { shared: string;