Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ import {
planCompositionAssembly,
extractedCompositionAssets,
} from "./compositionAssembly";
import { SCENE_EAGER_ATTR, SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts";
import {
REACHED_BY_ROOT_REASON,
READ_BY_SCENE_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.
Expand Down Expand Up @@ -164,7 +170,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}`);
}
}

Expand Down Expand Up @@ -840,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);
}
}
Expand Down
25 changes: 14 additions & 11 deletions packages/core/src/runtime/afterFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,28 @@ export const FONT_WAIT_TIMEOUT_MS = 5000;

(window as unknown as Record<string, unknown>)[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<void> {
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<string> | null): Promise<void> {
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<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
fonts.ready.then(() => false),
ready.then(() => false),
new Promise<boolean>((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(", ")}`,
);
Expand Down Expand Up @@ -126,8 +128,9 @@ function afterQueuedModules(): Promise<unknown> {
export async function runScriptsAfterFonts(
scripts: readonly Element[],
afterRun?: () => void,
families?: ReadonlySet<string> | null,
): Promise<void> {
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));
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 47 additions & 11 deletions packages/core/src/runtime/heldScenes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }));</script>`;

const host = (id: string, start: number, attrs = "") =>
`<div data-composition-id="${id}" data-hf-scene="${id}" data-start="${start}" data-duration="5"${attrs}><p>${id}</p></div>`;
const host = (id: string, start: number, attrs = "", font = "serif") =>
`<div data-composition-id="${id}" data-hf-scene="${id}" data-start="${start}" data-duration="5"${attrs}><p style="font-family: ${font}">${id}</p></div>`;

async function boot(preview: boolean) {
// A face per family, each loading until the test settles it.
function serveFonts(...families: string[]) {
const settle: Record<string, () => void> = {};
const faces = families.map((family) => {
const face = { family, status: "loading" as FontFaceLoadStatus, loaded: Promise.resolve() };
face.loaded = new Promise<void>((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)
Expand All @@ -39,11 +62,12 @@ async function boot(preview: boolean) {
);
document.body.innerHTML =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="60" data-width="1920" data-height="1080">` +
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"') +
`</div>` +
["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) => {
Expand All @@ -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;
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 "));
});
});
36 changes: 30 additions & 6 deletions packages/core/src/runtime/heldScenes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { 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";

Expand All @@ -19,16 +25,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) || eager.endsWith(READ_BY_SCENE_REASON)) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep already-eager dependency providers in the boot batch

keepScenesReadAtBootEager still skips any provider already carrying SCENE_EAGER_ATTR, so the absence of these two reason strings does not mean the scene is safe to move after boot. A valid compiler-produced example has host/script order c, b, a, starts 40s, 30s, 0s, and distinct paused timelines: b reads window.__timelines.c during initialization, and a initializes its visible label from whether window.__timelines.b exists. The compiler emits c=b reads it, b=its script names c, a=its script names b. BASE runs c,b,a, so a displays ready; HEAD keeps c,a in the boot batch and queues b afterward, so a displays missing even with every font already ready. Mounting b does not rerun a's initialization.

The follow-up protects a newly marked provider but misses this already-eager case. Preserve boot-required dependency priority independently of the first diagnostic reason (or promote it safely), and cover the compiler-produced c,b,a chain rather than only a manually stamped a reads it host. This is a source-traced ordering counterexample, not an executed browser reproduction.

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;
Expand Down Expand Up @@ -57,3 +65,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<string> | null {
if (held.size === 0 || !document.body) return null;
const waiting = new Set([...held.values()].map((scene) => scene.host));
const families = new Set<string>();
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;
}
1 change: 1 addition & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/sceneParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ 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";

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;
Expand Down
Loading