Skip to content
Open
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
145 changes: 145 additions & 0 deletions packages/core/src/compositionReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
paintAndIdleReadinessInput,
scanPendingCompositionAssets,
settleCompositionReadiness,
settleFirstFrameCompositionReadiness,
} from "./compositionReadiness.js";
import { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
import { isRuntimeElementVisibleAt } from "./runtime/timeline.js";

function docWith(bodyHtml: string): Document {
const doc = document.implementation.createHTMLDocument("");
Expand Down Expand Up @@ -63,6 +66,45 @@ describe("scanPendingCompositionAssets", () => {
const scan = scanPendingCompositionAssets(docWith('<img src="a.png">'));
expect(scan.pendingImages).toHaveLength(1);
});

it("scopes first-frame scans to assets active at t=0", () => {
const doc = docWith(
'<img id="first" data-start="0" data-duration="5" src="first.png">' +
'<video id="later" data-start="30" data-duration="5" src="later.mp4"></video>',
);
const scan = scanPendingCompositionAssets(doc, { scope: "first-frame" });

expect(scan.pendingImages.map((image) => image.id)).toEqual(["first"]);
expect(scan.pendingMedia.map((media) => media.id)).toEqual([]);
});

it("keeps untimed media in the first-frame scan", () => {
const doc = docWith('<video id="untimed" src="video.mp4"></video>');

const scan = scanPendingCompositionAssets(doc, { scope: "first-frame" });

expect(scan.pendingMedia.map((media) => media.id)).toEqual(["untimed"]);
});

it("keeps nested timing decisions aligned with the runtime visibility owner", () => {
const doc = docWith(
'<section data-start="30" data-duration="5"><video id="nested" src="later.mp4"></video></section>',
);
const nested = doc.querySelector<HTMLElement>("#nested")!;
const resolver = createRuntimeStartTimeResolver({ documentRef: doc });
const runtimeDecision = isRuntimeElementVisibleAt(doc.querySelector("section")!, {
currentTime: 0,
compositionDuration: Number.POSITIVE_INFINITY,
canonicalFps: 30,
exportRenderSeek: false,
timelineRegistry: {},
resolver,
});

expect(scanPendingCompositionAssets(doc, { scope: "first-frame" }).pendingMedia).toEqual([]);
expect(runtimeDecision).toBe(false);
expect(nested.closest("[data-start]")).not.toBeNull();
});
});

describe("mediaReadinessInput", () => {
Expand Down Expand Up @@ -96,6 +138,46 @@ describe("mediaReadinessInput", () => {
expect(result).toEqual({ timedOut: false });
vi.useRealTimers();
});

it("does not wait for a later first-frame video", async () => {
const doc = docWith(
'<img id="first" data-start="0" data-duration="5" src="first.png">' +
'<video id="later" data-start="30" data-duration="5" src="later.mp4"></video>',
);
const image = doc.querySelector<HTMLImageElement>("#first")!;
Object.defineProperty(image, "complete", { value: false });
let resolveImage!: () => void;
image.decode = () => new Promise<void>((resolve) => (resolveImage = resolve));
const pending = mediaReadinessInput(doc, new AbortController().signal, {
scope: "first-frame",
});

expect(pending).not.toBeNull();
resolveImage();
await expect(pending).resolves.toBeUndefined();
});

it("waits for later media in the default full scan", async () => {
const doc = docWith(
'<img id="first" data-start="0" data-duration="5" src="first.png">' +
'<video id="later" data-start="30" data-duration="5" src="later.mp4"></video>',
);
const image = doc.querySelector<HTMLImageElement>("#first")!;
const video = doc.querySelector<HTMLVideoElement>("#later")!;
Object.defineProperty(image, "complete", { value: false });
image.decode = () => Promise.resolve();
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
const pending = mediaReadinessInput(doc, new AbortController().signal, { scope: "all" });

let settled = false;
pending?.then(() => {
settled = true;
});
await flushMicrotasks();
expect(settled).toBe(false);
video.dispatchEvent(new Event("canplay"));
await expect(pending).resolves.toBeUndefined();
});
});

describe("computeReadinessInput", () => {
Expand Down Expand Up @@ -267,6 +349,69 @@ describe("paintAndIdleReadinessInput", () => {
});

describe("settleCompositionReadiness", () => {
it("does not wait for a later video through the public first-frame path", async () => {
const doc = docWith(
'<img id="first" data-start="0" data-duration="5" src="first.png">' +
'<video id="later" data-start="30" data-duration="5" src="later.mp4"></video>',
);
const image = doc.querySelector<HTMLImageElement>("#first")!;
const video = doc.querySelector<HTMLVideoElement>("#later")!;
Object.defineProperty(image, "complete", { value: false });
let resolveImage!: () => void;
image.decode = () => new Promise<void>((resolve) => (resolveImage = resolve));
let resolveFonts!: () => void;
Object.defineProperty(doc, "fonts", {
configurable: true,
value: {
status: "loading",
ready: new Promise<void>((resolve) => (resolveFonts = resolve)),
},
});
Object.defineProperty(video, "readyState", { value: 0, configurable: true });

let result: { timedOut: boolean } | undefined;
settleFirstFrameCompositionReadiness(
doc,
(settled) => {
result = settled;
},
{ timeoutMs: 50 },
);

await flushMicrotasks();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(result).toBeUndefined();
resolveImage();
await flushMicrotasks();
expect(result).toBeUndefined();
resolveFonts();
await flushMicrotasks();
expect(result).toEqual({ timedOut: false });
});

it("keeps the explicit full-scan path waiting for a later video", async () => {
const doc = docWith(
'<img id="first" data-start="0" data-duration="5" src="first.png">' +
'<video id="later" data-start="30" data-duration="5" src="later.mp4"></video>',
);
const image = doc.querySelector<HTMLImageElement>("#first")!;
const video = doc.querySelector<HTMLVideoElement>("#later")!;
Object.defineProperty(image, "complete", { value: true });
Object.defineProperty(video, "readyState", { value: 0, configurable: true });

let result: { timedOut: boolean } | undefined;
settleCompositionReadiness(
doc,
(settled) => {
result = settled;
},
{ scope: "all", timeoutMs: 1 },
);

await new Promise((resolve) => setTimeout(resolve, 5));
expect(result).toEqual({ timedOut: true });
});

it("defaults to media, compute and paint-and-idle together", async () => {
vi.useFakeTimers();
const { win, fireFrame } = docWithFakeWindow(false);
Expand Down
103 changes: 97 additions & 6 deletions packages/core/src/compositionReadiness.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
import { isRuntimeElementVisibleAt } from "./runtime/timeline.js";
import type { RuntimeTimelineLike } from "./runtime/types.js";

/** A composition is "ready" once every declared input settles, not just once
* its duration is known. Each input returns null (nothing to wait on) or a
* promise that resolves once it settles, and must stop its own pending work
Expand All @@ -14,6 +18,14 @@ export interface PendingCompositionAssets {
fontsLoading: boolean;
}

export type CompositionReadinessScope = "all" | "first-frame";

export const FIRST_FRAME_READINESS_SCOPE: CompositionReadinessScope = "first-frame";

export interface CompositionReadinessOptions {
scope?: CompositionReadinessScope;
}

// HTMLMediaElement.HAVE_FUTURE_DATA per spec, used as a literal because not
// every DOM implementation defines the named static (e.g. happy-dom leaves
// it undefined).
Expand All @@ -37,12 +49,67 @@ export function isRealmHtmlMediaElement(node: Node): node is HTMLMediaElement {
return node instanceof HTMLMediaElement;
}

function isTimedElement(element: Element): boolean {
return element.hasAttribute("data-start") || element.hasAttribute("data-track-index");
}

function isActiveAtFirstFrame(
element: Element,
resolver: ReturnType<typeof createRuntimeStartTimeResolver>,
timelineRegistry: Record<string, RuntimeTimelineLike | undefined>,
): boolean {
let current: Element | null = element;
while (current) {
if (isTimedElement(current)) {
if (
!isRuntimeElementVisibleAt(current as HTMLElement, {
currentTime: 0,
compositionDuration: Number.POSITIVE_INFINITY,
canonicalFps: 30,
exportRenderSeek: false,
timelineRegistry,
resolver,
})
)
return false;
}
current = current.parentElement;
}
return true;
}

function shouldIncludeAsset(
element: Element,
scope: CompositionReadinessScope,
resolver: ReturnType<typeof createRuntimeStartTimeResolver>,
timelineRegistry: Record<string, RuntimeTimelineLike | undefined>,
): boolean {
if (scope === "all") return true;
return isActiveAtFirstFrame(element, resolver, timelineRegistry);
}

/** One DOM pass for every declared-media asset not yet ready. */
export function scanPendingCompositionAssets(doc: Document): PendingCompositionAssets {
export function scanPendingCompositionAssets(
doc: Document,
{ scope = "all" }: CompositionReadinessOptions = {},
): PendingCompositionAssets {
const runtimeWindow = doc.defaultView as
| (Window & {
__timelines?: Record<string, import("./runtime/types").RuntimeTimelineLike | undefined>;
})
| null;
const resolver = createRuntimeStartTimeResolver({
documentRef: doc,
timelineRegistry: runtimeWindow?.__timelines,
includeAuthoredTimingAttrs: true,
});
const pendingMedia = Array.from(doc.querySelectorAll("video, audio"))
.filter(isRealmHtmlMediaElement)
.filter((el) => shouldIncludeAsset(el, scope, resolver, runtimeWindow?.__timelines ?? {}))
.filter((el) => el.readyState < HAVE_FUTURE_DATA);
const pendingImages = Array.from(doc.querySelectorAll("img")).filter((img) => !img.complete);
const pendingImages = Array.from(doc.querySelectorAll("img"))
.filter((img) => shouldIncludeAsset(img, scope, resolver, runtimeWindow?.__timelines ?? {}))
.filter((img) => !img.complete);
const fontsLoading = doc.fonts?.status === "loading";
return { pendingMedia, pendingImages, fontsLoading };
}
Expand Down Expand Up @@ -82,8 +149,12 @@ function collectPendingCompositionAssets(

/** Declared-media readiness input: waits on the composition's own video,
* audio, image and font-face loads. */
export function mediaReadinessInput(doc: Document, signal: AbortSignal): Promise<void> | null {
const scan = scanPendingCompositionAssets(doc);
export function mediaReadinessInput(
doc: Document,
signal: AbortSignal,
{ scope = "all" }: CompositionReadinessOptions = {},
): Promise<void> | null {
const scan = scanPendingCompositionAssets(doc, { scope });
if (scan.pendingMedia.length === 0 && scan.pendingImages.length === 0 && !scan.fontsLoading) {
return null;
}
Expand Down Expand Up @@ -216,10 +287,13 @@ export interface CompositionReadinessResult {
export function settleCompositionReadiness(
doc: Document,
onSettled: (result: CompositionReadinessResult) => void,
opts: { inputs?: CompositionReadinessInput[]; timeoutMs?: number } = {},
opts: CompositionReadinessOptions & {
inputs?: CompositionReadinessInput[];
timeoutMs?: number;
} = {},
): void {
const inputs = opts.inputs ?? [
mediaReadinessInput,
(inputDoc, signal) => mediaReadinessInput(inputDoc, signal, { scope: opts.scope }),
computeReadinessInput,
paintAndIdleReadinessInput,
];
Expand Down Expand Up @@ -247,3 +321,20 @@ export function settleCompositionReadiness(
onSettled({ timedOut: result === "timed-out" });
});
}

export function settleFirstFrameCompositionReadiness(
doc: Document,
onSettled: (result: CompositionReadinessResult) => void,
opts: Omit<
CompositionReadinessOptions & {
inputs?: CompositionReadinessInput[];
timeoutMs?: number;
},
"scope"
> = {},
): void {
settleCompositionReadiness(doc, onSettled, {
...opts,
scope: FIRST_FRAME_READINESS_SCOPE,
});
}
3 changes: 3 additions & 0 deletions packages/core/src/runtime/authoredTiming.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export type AuthoredTimingValue = string | number | null | undefined;

export const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
export const AUTHORED_END_ATTR = "data-hf-authored-end";

export interface RawAuthoredTiming {
start?: AuthoredTimingValue;
duration?: AuthoredTimingValue;
Expand Down
Loading
Loading