mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 05:46:40 +00:00
* fix(core): preview repaints and rebuilds effects after the GPU restores a lost WebGL context * fix(core): a paused scene redraws and a playing one repaints effects after a context restore * test(core): a paused scene is redrawn after a WebGL context restore * test(core): give the restore redraw test a clip * fix(core): shorten the context recovery header comment * fix(core): rebuild effects only after every lost context has restored * test(core): the restore rebuild waits while another context is still lost * test(core): count context requests to show the rebuild waits * test(core): the mock reports the lost flag the test sets
1124 lines
43 KiB
TypeScript
1124 lines
43 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { mediaElementTime } from "./mediaClock";
|
|
import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
|
|
import {
|
|
PLAYER_FRAME_ATTR,
|
|
STUDIO_PREVIEW_LAZY_ATTR,
|
|
STUDIO_PREVIEW_MARK_META,
|
|
} from "../studioPreviewMark";
|
|
import { revealClip } from "./clipReveal";
|
|
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");
|
|
}
|
|
|
|
function resetRuntimeGlobals(): void {
|
|
vi.useRealTimers();
|
|
window.__hfRuntimeTeardown?.();
|
|
document.head.innerHTML = "";
|
|
document.body.innerHTML = "";
|
|
window.__timelines = {};
|
|
delete window.__player;
|
|
delete window.__playerReady;
|
|
delete window.__HF_RELEASE_PASSED_MEDIA;
|
|
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;
|
|
}
|
|
|
|
// The first evaluation transforms the whole runtime graph. Paying it while the file loads keeps
|
|
// that one-time cost out of the first test's timeout; every test still evaluates a fresh copy.
|
|
await evaluateRuntime();
|
|
resetRuntimeGlobals();
|
|
|
|
const visibility = (...els: HTMLElement[]) => els.map((el) => getComputedStyle(el).visibility);
|
|
const imageSkipped = (...clips: HTMLElement[]) =>
|
|
clips.map((clip) => getComputedStyle(clip.querySelector("img")!).display === "none");
|
|
const withImage = <T extends HTMLElement>(clip: T): T => {
|
|
clip.appendChild(document.createElement("img"));
|
|
return clip;
|
|
};
|
|
// What Studio's preview route serves at the head start; render and player documents never carry it.
|
|
const servePreview = () =>
|
|
document.head.appendChild(
|
|
Object.assign(document.createElement("meta"), { name: STUDIO_PREVIEW_MARK_META }),
|
|
);
|
|
const asSafari = () =>
|
|
vi
|
|
.spyOn(navigator, "userAgent", "get")
|
|
.mockReturnValue(
|
|
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
);
|
|
const neverDecodes = (clip: HTMLElement) => {
|
|
withImage(clip).querySelector("img")!.decode = () => new Promise<void>(() => {});
|
|
return clip;
|
|
};
|
|
|
|
describe("runtime entry", () => {
|
|
afterEach(resetRuntimeGlobals);
|
|
|
|
it("starts a held capture-motion-blur frame when capture waits on the seek, after video injection", async () => {
|
|
const release = vi.fn();
|
|
vi.doMock("./captureMotionBlur", async (original) => ({
|
|
...(await original<typeof import("./captureMotionBlur")>()),
|
|
releaseCaptureMotionBlurFrame: release,
|
|
}));
|
|
try {
|
|
mountRoot().appendChild(document.createElement("div"));
|
|
await evaluateRuntime();
|
|
expect(release).not.toHaveBeenCalled();
|
|
await window.__hfWaitForSeekCompletion?.();
|
|
expect(release).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.doUnmock("./captureMotionBlur");
|
|
}
|
|
});
|
|
|
|
it("drives an async frame source without a GSAP timeline and waits before capture", async () => {
|
|
const root = mountRoot();
|
|
root.setAttribute("data-duration", "10");
|
|
root.appendChild(document.createElement("div"));
|
|
window.__timelines = {};
|
|
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
|
|
await evaluateRuntime();
|
|
expect(window.__hyperframes!.createFilmBridge).toEqual(expect.any(Function));
|
|
let finish!: () => void;
|
|
const first = new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
const render = vi
|
|
.fn()
|
|
.mockImplementationOnce(() => first)
|
|
.mockResolvedValue(undefined);
|
|
window.__hyperframes!.registerFrameSource({ element: root, render });
|
|
delete (document as { readyState?: unknown }).readyState;
|
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
await vi.waitFor(() => expect(render).toHaveBeenCalled());
|
|
window.__player!.seek(1.2);
|
|
let captured = false;
|
|
const capture = window.__hfWaitForSeekCompletion!().then(() => {
|
|
captured = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(captured).toBe(false);
|
|
finish();
|
|
await capture;
|
|
expect(render).toHaveBeenLastCalledWith(1.2, expect.any(AbortSignal));
|
|
});
|
|
|
|
it("paints no timed clip, from script evaluation until the first visibility pass decides it", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = timed(root, "div", "5");
|
|
const poster = timed(root, "img", "0");
|
|
// Studio serves later scenes' images lazy; laid out, they would load before the first pass.
|
|
const plate = later.appendChild(document.createElement("img"));
|
|
plate.setAttribute("loading", "lazy");
|
|
// 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"]);
|
|
expect(getComputedStyle(plate).display).toBe("none");
|
|
|
|
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"]);
|
|
expect(document.querySelector("style[data-hf-first-pass-hide]")).toBeNull();
|
|
});
|
|
|
|
it("gives a render document neither rule, no look-ahead marks and no held seeks", async () => {
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = neverDecodes(timed(root, "div", "5"));
|
|
const plate = later.querySelector("img")!;
|
|
plate.setAttribute("loading", "lazy");
|
|
window.__hfTimelinesBuilding = true;
|
|
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
|
|
|
|
await evaluateRuntime();
|
|
// A setup-time measure of an authored lazy image must see it laid out.
|
|
expect(getComputedStyle(plate).display).not.toBe("none");
|
|
delete (document as { readyState?: unknown }).readyState;
|
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
window.__hfTimelinesBuilding = false;
|
|
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
|
|
window.__player?.seek(3.5);
|
|
expect(imageSkipped(later)).toEqual([false]);
|
|
expect(document.querySelector("[data-hf-upcoming]")).toBeNull();
|
|
window.__player?.seek(5.5);
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
});
|
|
|
|
it("keys preview mode on the preview meta alone, not on the GSAP fallback script captures keep", async () => {
|
|
document.head
|
|
.appendChild(document.createElement("script"))
|
|
.setAttribute("data-hf-gsap-fallback", "");
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = neverDecodes(timed(root, "div", "5"));
|
|
const plate = later.querySelector("img")!;
|
|
plate.setAttribute("loading", "lazy");
|
|
window.__hfTimelinesBuilding = true;
|
|
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
|
|
|
|
await evaluateRuntime();
|
|
expect(getComputedStyle(plate).display).not.toBe("none");
|
|
delete (document as { readyState?: unknown }).readyState;
|
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
window.__hfTimelinesBuilding = false;
|
|
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
|
|
expect(document.querySelector("style[data-hf-skip-hidden-images]")).toBeNull();
|
|
window.__player?.seek(3.5);
|
|
expect(imageSkipped(later)).toEqual([false]);
|
|
expect(document.querySelector("[data-hf-upcoming]")).toBeNull();
|
|
expect(window.__player?.seek(5.5)).toBeUndefined();
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
});
|
|
|
|
it("skips the images of each hidden clip not due within the look-ahead, until something shows it", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = withImage(timed(root, "div", "0"));
|
|
const soon = withImage(timed(root, "div", "1.5"));
|
|
const later = withImage(timed(root, "div", "5"));
|
|
// Shorter than the look-ahead: due within it, though already over at its far end.
|
|
const brief = withImage(timed(root, "div", "5"));
|
|
brief.setAttribute("data-duration", "0.5");
|
|
|
|
await evaluateRuntime();
|
|
expect(imageSkipped(current, soon, later, brief)).toEqual([false, false, true, true]);
|
|
window.__player?.seek(3.6);
|
|
expect(imageSkipped(later, brief)).toEqual([false, false]);
|
|
window.__player?.seek(0);
|
|
// How Studio's layer reveal shows a hidden clip.
|
|
revealClip(later);
|
|
expect(imageSkipped(later)).toEqual([false]);
|
|
});
|
|
|
|
it("loads the next scene's images within the look-ahead while its clip is still hidden", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
const soon = timed(root, "div", "1.5");
|
|
const later = timed(root, "div", "5");
|
|
const lazyPlate = (clip: HTMLElement) => {
|
|
const img = clip.appendChild(document.createElement("img"));
|
|
img.setAttribute("loading", "lazy");
|
|
img.setAttribute(STUDIO_PREVIEW_LAZY_ATTR, "");
|
|
return img;
|
|
};
|
|
const plate = lazyPlate(soon);
|
|
const farPlate = lazyPlate(later);
|
|
// A clip nested in the coming one waits for its own look-ahead.
|
|
const nestedPlate = lazyPlate(timed(soon, "div", "8"));
|
|
const authored = soon.appendChild(document.createElement("img"));
|
|
authored.setAttribute("loading", "lazy");
|
|
|
|
await evaluateRuntime();
|
|
expect(visibility(soon)).toEqual(["hidden"]);
|
|
expect(
|
|
[plate, farPlate, nestedPlate, authored].map((img) => img.getAttribute("loading")),
|
|
).toEqual(["eager", "lazy", "lazy", "lazy"]);
|
|
window.__player?.seek(3.5);
|
|
expect(farPlate.getAttribute("loading")).toBe("eager");
|
|
});
|
|
|
|
it("holds a paused jump on the previous picture until the next scene's image decodes", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = timed(root, "div", "5");
|
|
const plate = later.appendChild(document.createElement("img"));
|
|
plate.setAttribute("loading", "lazy");
|
|
plate.setAttribute(STUDIO_PREVIEW_LAZY_ATTR, "");
|
|
let decoded = () => {};
|
|
plate.decode = () => new Promise<void>((resolve) => (decoded = resolve));
|
|
const authored = later.appendChild(document.createElement("img"));
|
|
authored.setAttribute("loading", "lazy");
|
|
authored.decode = () => Promise.resolve();
|
|
|
|
await evaluateRuntime();
|
|
expect(window.__player?.seek(0.5)).toBeUndefined();
|
|
const landed = window.__player?.seek(5.5);
|
|
expect(landed).toBeInstanceOf(Promise);
|
|
// Any frame painted now shows the previous scene, while the next one is unskipped so its image loads.
|
|
expect(visibility(current, later)).toEqual(["visible", "hidden"]);
|
|
expect(imageSkipped(later)).toEqual([false]);
|
|
expect(window.__player?.getTime()).toBe(5.5);
|
|
expect([plate, authored].map((img) => img.getAttribute("loading"))).toEqual(["eager", "lazy"]);
|
|
decoded();
|
|
await landed;
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
await window.__hfWaitForSeekCompletion?.();
|
|
});
|
|
|
|
it("applies a held jump after the cap when an image never decodes", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = timed(root, "div", "5");
|
|
later.appendChild(document.createElement("img")).decode = () => new Promise<void>(() => {});
|
|
|
|
await evaluateRuntime();
|
|
const swallowed: string[] = [];
|
|
window.__hf = {
|
|
...window.__hf,
|
|
onSwallowed: ({ label }: { label: string }) => swallowed.push(label),
|
|
};
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
window.__player?.seek(5.5);
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(visibility(current, later)).toEqual(["visible", "hidden"]);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
expect(swallowed).toEqual(["runtime.init.seekHoldCap"]);
|
|
});
|
|
|
|
it("drops a held jump when a newer seek or a render seek lands first", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const mid = timed(root, "div", "1");
|
|
const later = timed(root, "div", "5");
|
|
const plate = later.appendChild(document.createElement("img"));
|
|
let decoded = () => {};
|
|
plate.decode = () => new Promise<void>((resolve) => (decoded = resolve));
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(5.5);
|
|
window.__player?.seek(1);
|
|
expect(visibility(current, mid, later)).toEqual(["visible", "visible", "hidden"]);
|
|
decoded();
|
|
await window.__hfWaitForSeekCompletion?.();
|
|
expect(visibility(current, mid, later)).toEqual(["visible", "visible", "hidden"]);
|
|
expect(window.__player?.getTime()).toBe(1);
|
|
|
|
window.__player?.seek(0);
|
|
window.__player?.seek(5.5);
|
|
window.__player?.renderSeek(1);
|
|
decoded();
|
|
await window.__hfWaitForSeekCompletion?.();
|
|
expect(visibility(current, mid, later)).toEqual(["visible", "visible", "hidden"]);
|
|
});
|
|
|
|
it("never holds a render seek", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = neverDecodes(timed(root, "div", "5"));
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.renderSeek(5.5);
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
});
|
|
|
|
it("shows the jump target at once when play is pressed during the hold", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const current = timed(root, "div", "0");
|
|
const later = timed(root, "div", "5");
|
|
later.appendChild(document.createElement("img")).decode = () => new Promise<void>(() => {});
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(5.5);
|
|
window.__player?.play();
|
|
expect(visibility(current, later)).toEqual(["hidden", "visible"]);
|
|
});
|
|
|
|
it("redraws a paused scene by stepping its timeline off the current time and back after a WebGL restore", async () => {
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
const timeline = window.__timelines.main!;
|
|
await evaluateRuntime();
|
|
window.__player!.seek(2);
|
|
const seen: number[] = [];
|
|
const totalTime = timeline.totalTime!;
|
|
timeline.totalTime = (t?: number) => {
|
|
if (t !== undefined) seen.push(t);
|
|
return totalTime(t as number);
|
|
};
|
|
const frames: FrameRequestCallback[] = [];
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
|
|
|
|
const scene = root.appendChild(document.createElement("canvas"));
|
|
scene.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
|
scene.dispatchEvent(new Event("webglcontextrestored"));
|
|
seen.length = 0;
|
|
frames.splice(0).forEach((cb) => cb(0));
|
|
|
|
expect(seen[0]).toBeCloseTo(2 - 1e-4, 6);
|
|
expect(seen.at(-1)).toBeCloseTo(2, 3);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
describe("media preload window", () => {
|
|
const loads: HTMLMediaElement[] = [];
|
|
const spyLoad = () => {
|
|
loads.length = 0;
|
|
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(
|
|
function (this: HTMLMediaElement) {
|
|
loads.push(this);
|
|
},
|
|
);
|
|
};
|
|
const loadsOf = (...els: HTMLMediaElement[]) =>
|
|
els.map((el) => loads.filter((loaded) => loaded === el).length);
|
|
const videos = (root: HTMLElement, ...starts: string[]) =>
|
|
starts.map((start) => timed(root, "video", start));
|
|
const armed = (...els: HTMLMediaElement[]) => els.map((el) => el.preload === "auto");
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it("loads a preview's media only on screen or due within the look-ahead, and frees what the playhead left", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [now, soon, later] = videos(mountRoot(), "0", "1.5", "9");
|
|
|
|
await evaluateRuntime();
|
|
expect(armed(now, soon, later)).toEqual([true, true, false]);
|
|
expect(later.preload).toBe("none");
|
|
expect(loadsOf(now, soon, later)).toEqual([1, 1, 0]);
|
|
|
|
now.setAttribute("src", "now.mp4");
|
|
const srcWrites = new MutationObserver(() => {});
|
|
srcWrites.observe(now, { attributeFilter: ["src"] });
|
|
window.__player?.seek(7.6);
|
|
expect(armed(now, soon, later)).toEqual([false, false, true]);
|
|
// The two that left are reloaded empty with src dropped for the reload, ending any fetch in flight.
|
|
expect([now.preload, soon.preload]).toEqual(["none", "none"]);
|
|
expect(loadsOf(now, soon, later)).toEqual([2, 2, 1]);
|
|
expect(srcWrites.takeRecords()).toHaveLength(2);
|
|
expect(now.getAttribute("src")).toBe("now.mp4");
|
|
});
|
|
|
|
// Three takes of one file, each a picture and a sound, as an edit cut from a single source is.
|
|
const takesOfOneFile = () => {
|
|
const root = mountRoot();
|
|
const take = (start: string) => {
|
|
const pair = [timed(root, "video", start), timed(root, "audio", start)];
|
|
for (const el of pair) el.setAttribute("src", "/source.mp4");
|
|
return pair;
|
|
};
|
|
return [take("0"), take("3"), take("9")];
|
|
};
|
|
const inPlayerFrame = (userAgent: "safari" | "chrome") => {
|
|
const frame = document.createElement("iframe");
|
|
frame.setAttribute(PLAYER_FRAME_ATTR, "");
|
|
vi.spyOn(window, "frameElement", "get").mockReturnValue(frame);
|
|
if (userAgent === "safari") asSafari();
|
|
};
|
|
|
|
it("in the player on Safari a file keeps the take on screen and the next one loading, and frees the rest", async () => {
|
|
spyLoad();
|
|
inPlayerFrame("safari");
|
|
const [first, second, third] = takesOfOneFile();
|
|
|
|
await evaluateRuntime();
|
|
expect([first, second, third].map((take) => armed(...take))).toEqual([
|
|
[true, true],
|
|
[true, true],
|
|
[false, false],
|
|
]);
|
|
|
|
window.__player?.seek(3.5);
|
|
expect([first, second, third].map((take) => armed(...take))).toEqual([
|
|
[false, false],
|
|
[true, true],
|
|
[true, true],
|
|
]);
|
|
});
|
|
|
|
it("in the player on Chromium every take of a file loads at parse", async () => {
|
|
spyLoad();
|
|
inPlayerFrame("chrome");
|
|
const takes = takesOfOneFile();
|
|
|
|
await evaluateRuntime();
|
|
expect(takes.map((take) => armed(...take))).toEqual([
|
|
[true, true],
|
|
[true, true],
|
|
[true, true],
|
|
]);
|
|
});
|
|
|
|
it("in Studio's preview a clip 5 s ahead stays unloaded, as the look-ahead is 2 s", async () => {
|
|
servePreview();
|
|
const [now, soon] = videos(mountRoot(), "0", "5");
|
|
|
|
await evaluateRuntime();
|
|
expect(armed(now, soon)).toEqual([true, false]);
|
|
});
|
|
|
|
it("loads a clip with no authored length as a render does, so no reload drops its duration", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [untrimmed, later] = videos(mountRoot(), "0", "8");
|
|
untrimmed.removeAttribute("data-duration");
|
|
later.removeAttribute("data-duration");
|
|
Object.defineProperty(untrimmed, "readyState", { value: 1 });
|
|
Object.defineProperty(untrimmed, "duration", { value: 2 });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.6);
|
|
window.__player?.seek(0.5);
|
|
window.__player?.seek(7.5);
|
|
expect(armed(untrimmed, later)).toEqual([true, true]);
|
|
expect(loadsOf(untrimmed, later)).toEqual([1, 1]);
|
|
});
|
|
|
|
it("arms a clip a far jump lands on without reloading it under the seek", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [now, later] = videos(mountRoot(), "0", "8");
|
|
|
|
await evaluateRuntime();
|
|
const beforeJump = loadsOf(later)[0];
|
|
window.__player?.seek(8.4);
|
|
expect(armed(now, later)).toEqual([false, true]);
|
|
expect(loadsOf(later)).toEqual([beforeJump]);
|
|
});
|
|
|
|
it("fetches the clips due soon three at a time, earliest first, so the clip due never queues behind them", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [now, ...due] = videos(mountRoot(), "0", "1.5", "1.1", "1.4", "1.2", "1.3");
|
|
|
|
await evaluateRuntime();
|
|
expect(armed(now, ...due)).toEqual([true, true, true, true, false, false]);
|
|
expect(loadsOf(...due)).toEqual([1, 1, 1, 0, 0]);
|
|
due[0]!.dispatchEvent(new Event("suspend"));
|
|
expect(armed(...due)).toEqual([true, true, true, false, false]);
|
|
Object.defineProperty(due[0]!, "readyState", { value: 3 });
|
|
due[0]!.dispatchEvent(new Event("canplay"));
|
|
expect(armed(...due)).toEqual([true, true, true, true, false]);
|
|
due[1]!.dispatchEvent(new Event("error"));
|
|
expect(armed(...due)).toEqual([true, true, true, true, true]);
|
|
});
|
|
|
|
it("refreshes queued prefetch priorities after a composition timing edit", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const music = timed(root, "audio", "0.5");
|
|
music.setAttribute("data-duration", "3600");
|
|
Object.defineProperty(music, "duration", { configurable: true, value: 3600 });
|
|
const [first, second, third, movedEarlier] = videos(root, "0.8", "1", "1.2", "1.3");
|
|
|
|
await evaluateRuntime();
|
|
expect(armed(music, first, second, third, movedEarlier)).toEqual([
|
|
true,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
]);
|
|
|
|
movedEarlier.setAttribute("data-start", "0.6");
|
|
window.__player?.seek(0.01);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
Object.defineProperty(first, "readyState", { configurable: true, value: 3 });
|
|
first.dispatchEvent(new Event("canplay"));
|
|
|
|
expect(armed(movedEarlier)).toEqual([true]);
|
|
expect(armed(third)).toEqual([false]);
|
|
});
|
|
|
|
it("keeps the long-track prefetch wave ordered across canplay settlements", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const music = timed(root, "audio", "0.5");
|
|
music.setAttribute("data-duration", "3600");
|
|
Object.defineProperty(music, "duration", { configurable: true, value: 3600 });
|
|
const waiting = Array.from({ length: 128 }, (_, index) => {
|
|
// A deterministic shuffled window makes the priority sort exercise its comparator.
|
|
const start = 0.1 + (((index * 197) % 509) / 509) * 1.8;
|
|
return timed(root, "video", String(start));
|
|
});
|
|
|
|
const getAttribute = vi.spyOn(Element.prototype, "getAttribute");
|
|
await evaluateRuntime();
|
|
expect(loadsOf(music)).toEqual([1]);
|
|
const settled = new Set<HTMLMediaElement>();
|
|
const candidates = [music, ...waiting];
|
|
let fillCalls = 0;
|
|
while (settled.size < candidates.length) {
|
|
const active = candidates.filter(
|
|
(element) => !settled.has(element) && element.preload === "auto",
|
|
);
|
|
if (active.length === 0) break;
|
|
for (const element of active) {
|
|
settled.add(element);
|
|
Object.defineProperty(element, "readyState", { configurable: true, value: 3 });
|
|
element.dispatchEvent(new Event("canplay"));
|
|
fillCalls++;
|
|
}
|
|
}
|
|
|
|
// Every settled media opens one slot; priorities must not be re-read for each refill.
|
|
const startReads = getAttribute.mock.calls.filter(([name]) => name === "data-start").length;
|
|
expect(fillCalls).toBe(candidates.length);
|
|
expect(startReads).toBeLessThan(waiting.length * 60);
|
|
expect(armed(...waiting).filter(Boolean)).toHaveLength(waiting.length);
|
|
getAttribute.mockRestore();
|
|
}, 20_000);
|
|
|
|
it("prepares a queued split only after a prefetch slot opens", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [now, first, second, third, queued] = videos(
|
|
mountRoot(),
|
|
"0",
|
|
"1",
|
|
"1.2",
|
|
"1.4",
|
|
"1.6",
|
|
);
|
|
for (const video of [first, second, third, queued])
|
|
video.setAttribute("data-media-start", "6");
|
|
|
|
await evaluateRuntime();
|
|
expect(armed(now, first, second, third, queued)).toEqual([true, true, true, true, false]);
|
|
queued.dispatchEvent(new Event("loadedmetadata"));
|
|
expect(queued.currentTime).toBe(0);
|
|
|
|
first.dispatchEvent(new Event("error"));
|
|
queued.dispatchEvent(new Event("loadedmetadata"));
|
|
expect(queued.preload).toBe("auto");
|
|
expect(queued.currentTime).toBe(mediaElementTime(6));
|
|
expect(loadsOf(queued)).toEqual([1]);
|
|
});
|
|
|
|
it.each([
|
|
[true, 2],
|
|
[false, 1],
|
|
])(
|
|
"with a capture page flag %s, a fully buffered clip the playhead left is reloaded %s times",
|
|
async (flag, loadCount) => {
|
|
spyLoad();
|
|
servePreview();
|
|
window.__HF_RELEASE_PASSED_MEDIA = flag;
|
|
const [now, later] = videos(mountRoot(), "0", "9");
|
|
Object.defineProperty(now, "duration", { value: 5 });
|
|
Object.defineProperty(now, "buffered", {
|
|
value: { length: 1, start: () => 0, end: () => 5 },
|
|
});
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(7.6);
|
|
expect(armed(now, later)).toEqual([false, true]);
|
|
expect(loadsOf(now)).toEqual([loadCount]);
|
|
},
|
|
);
|
|
|
|
it("frees a clip the playhead has passed while it is still downloading, and keeps one that finished", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
stubPlayback();
|
|
const [slow, done] = videos(mountRoot(), "0", "0.5");
|
|
Object.defineProperty(slow, "networkState", { value: 2 });
|
|
Object.defineProperty(done, "networkState", { value: 1 });
|
|
Object.defineProperty(done, "readyState", { value: 4 });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
expect(armed(slow, done)).toEqual([true, true]);
|
|
window.__player?.seek(2.6, { keepPlaying: true });
|
|
expect(armed(slow, done)).toEqual([false, true]);
|
|
});
|
|
|
|
it("keeps a passed clip released for the rest of the play instead of refetching it each pass", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
stubPlayback();
|
|
const [slow, next] = videos(mountRoot(), "0", "2");
|
|
// A fetch is running whenever preload asks for one.
|
|
Object.defineProperty(slow, "networkState", { get: () => (slow.preload === "auto" ? 2 : 1) });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
window.__player?.seek(2.05, { keepPlaying: true });
|
|
expect(armed(slow, next)).toEqual([false, true]);
|
|
const loadsAfterRelease = loadsOf(slow)[0];
|
|
for (let t = 2.1; t <= 4.0; t += 0.05) {
|
|
window.__player?.seek(t, { keepPlaying: true });
|
|
expect(slow.preload).toBe("none");
|
|
}
|
|
expect(loadsOf(slow)[0]).toBe(loadsAfterRelease);
|
|
});
|
|
|
|
// jsdom's media elements cannot play; the runtime only needs play() to settle.
|
|
const stubPlayback = () => {
|
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
|
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
|
};
|
|
const tracks = (root: HTMLElement) => {
|
|
stubPlayback();
|
|
const [now, next, afterNext, otherTrack] = videos(root, "0", "4", "7", "3");
|
|
otherTrack.setAttribute("data-track-index", "2");
|
|
return { now, next, afterNext, otherTrack };
|
|
};
|
|
|
|
it("decodes an upcoming split video at its source offset before playback reaches it", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next } = tracks(mountRoot());
|
|
next.setAttribute("data-media-start", "6");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
next.dispatchEvent(new Event("loadedmetadata"));
|
|
expect(next.currentTime).toBe(mediaElementTime(6));
|
|
expect(next.paused).toBe(true);
|
|
expect(visibility(next)).toEqual(["hidden"]);
|
|
});
|
|
|
|
it("prepares a loaded upcoming video when the paused playhead enters its look-ahead", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [, next] = videos(mountRoot(), "0", "5");
|
|
next.setAttribute("data-media-start", "6");
|
|
Object.defineProperty(next, "readyState", { value: 4 });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.5);
|
|
expect(next.currentTime).toBe(mediaElementTime(6));
|
|
expect(next.paused).toBe(true);
|
|
expect(loadsOf(next)).toEqual([0]);
|
|
});
|
|
|
|
it("leaves a late preload callback out of a jump that already reached the video", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [, next] = videos(mountRoot(), "0", "5");
|
|
next.setAttribute("data-media-start", "6");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.5);
|
|
window.__player?.seek(5.5);
|
|
expect(next.currentTime).toBe(mediaElementTime(6.5));
|
|
next.dispatchEvent(new Event("loadedmetadata"));
|
|
expect(next.currentTime).toBe(mediaElementTime(6.5));
|
|
});
|
|
|
|
it("does not rewind a loaded outgoing video while it remains in the trailing window", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [previous] = videos(mountRoot(), "0", "2");
|
|
previous.setAttribute("data-media-start", "6");
|
|
Object.defineProperty(previous, "readyState", { value: 4 });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(1.5);
|
|
expect(previous.currentTime).toBe(mediaElementTime(7.5));
|
|
window.__player?.seek(2.1);
|
|
expect(previous.currentTime).toBe(mediaElementTime(7.5));
|
|
});
|
|
|
|
it("prepares a split again on replay when the loaded clip never leaves the window", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [, next] = videos(mountRoot(), "0", "2");
|
|
next.setAttribute("data-media-start", "6");
|
|
Object.defineProperty(next, "readyState", { value: 4 });
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.5);
|
|
expect(next.currentTime).toBe(mediaElementTime(7.5));
|
|
window.__player?.seek(0);
|
|
expect(next.currentTime).toBe(mediaElementTime(6));
|
|
expect(loadsOf(next)).toEqual([0]);
|
|
});
|
|
|
|
it("arms the next clip on a clip's track when it starts playing, and nothing else", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next, afterNext, otherTrack } = tracks(mountRoot());
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
expect(armed(next, afterNext, otherTrack)).toEqual([true, false, false]);
|
|
expect(loadsOf(next, afterNext, otherTrack)).toEqual([1, 0, 0]);
|
|
});
|
|
|
|
it("arms nothing ahead of a paused playhead, and keeps what it armed through a pause", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next, afterNext } = tracks(mountRoot());
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(0.5);
|
|
expect(armed(next)).toEqual([false]);
|
|
window.__player?.play();
|
|
window.__player?.pause();
|
|
expect(armed(next, afterNext)).toEqual([true, false]);
|
|
expect(loadsOf(next)).toEqual([1]);
|
|
});
|
|
|
|
it("holds a clip past the look-ahead edge a scrub crosses, and frees it once well clear", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [, later] = videos(mountRoot(), "0", "5");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.5);
|
|
window.__player?.seek(2.5);
|
|
window.__player?.seek(3.5);
|
|
expect(armed(later)).toEqual([true]);
|
|
expect(loadsOf(later)).toEqual([1]);
|
|
window.__player?.seek(0.5);
|
|
expect(armed(later)).toEqual([false]);
|
|
});
|
|
|
|
it("keeps the clip a step back across a cut returns to loaded", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [outgoing, incoming] = videos(mountRoot(), "0", "2");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(2.1);
|
|
expect(armed(outgoing, incoming)).toEqual([true, true]);
|
|
window.__player?.seek(1.9);
|
|
expect(armed(outgoing)).toEqual([true]);
|
|
expect(loadsOf(outgoing)).toEqual([1]);
|
|
});
|
|
|
|
it("arms a clip behind a playhead shuttling back before the playhead reaches it", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [earlier, current] = videos(mountRoot(), "0", "2");
|
|
current.setAttribute("data-duration", "6");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(6.5);
|
|
expect(armed(earlier)).toEqual([false]);
|
|
let armedAt: number | undefined;
|
|
for (let tenths = 45; tenths >= 20 && armedAt === undefined; tenths--) {
|
|
window.__player?.seek(tenths / 10);
|
|
if (armed(earlier)[0]) armedAt = tenths / 10;
|
|
}
|
|
// Within the 2 s look-ahead of its end, as a clip ahead is armed within 2 s of its start.
|
|
expect(armedAt).toBe(4);
|
|
});
|
|
|
|
it("still frees a clip that ended well behind the playhead", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const [earlier, current] = videos(mountRoot(), "0", "2");
|
|
current.setAttribute("data-duration", "6");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(2.1);
|
|
window.__player?.seek(5.9);
|
|
expect(armed(earlier)).toEqual([true]);
|
|
expect(loadsOf(earlier)).toEqual([1]);
|
|
window.__player?.seek(6.1);
|
|
expect(armed(earlier)).toEqual([false]);
|
|
expect(earlier.preload).toBe("none");
|
|
expect(loadsOf(earlier)).toEqual([2]);
|
|
});
|
|
|
|
it("drops an arm once the clip that set it leaves the screen, as a jump while playing does", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next } = tracks(mountRoot());
|
|
window.__timelines = { main: pausedTimeline(20) };
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
window.__player?.seek(10.5, { keepPlaying: true });
|
|
expect(armed(next)).toEqual([false]);
|
|
});
|
|
|
|
it("stops holding a clip it armed once that clip has played", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next } = tracks(mountRoot());
|
|
window.__timelines = { main: pausedTimeline(20) };
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
window.__player?.seek(4.5, { keepPlaying: true });
|
|
window.__player?.seek(10.5, { keepPlaying: true });
|
|
expect(armed(next)).toEqual([false]);
|
|
});
|
|
|
|
it("arms a clip that starts exactly where the playing one ends", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
stubPlayback();
|
|
// 1.1 + 2.2 is 3.3000000000000003: back to back only within the boundary tolerance.
|
|
const [first, second] = videos(mountRoot(), "1.1", "3.3");
|
|
first.setAttribute("data-duration", "2.2");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(1.2);
|
|
window.__player?.play();
|
|
expect(armed(second)).toEqual([true]);
|
|
});
|
|
|
|
it("arms the next clip only in the same host and on an authored track", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
stubPlayback();
|
|
const root = mountRoot();
|
|
const [now] = videos(root, "0");
|
|
const host = timed(root, "div", "0");
|
|
host.setAttribute("data-composition-id", "inner");
|
|
host.setAttribute("data-duration", "10");
|
|
const [otherHost] = videos(host, "4");
|
|
const [untracked, untrackedNext] = videos(root, "0", "4");
|
|
for (const el of [untracked, untrackedNext]) el.removeAttribute("data-track-index");
|
|
now.setAttribute("data-track-index", "1");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
expect(armed(otherHost, untrackedNext)).toEqual([false, false]);
|
|
});
|
|
|
|
it("arms a short nested clip whose start is already root time before it is due", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const host = timed(mountRoot(), "div", "5");
|
|
host.setAttribute("data-composition-id", "inner");
|
|
host.setAttribute("data-duration", "5");
|
|
const [clip] = videos(host, "6");
|
|
clip.setAttribute("data-hf-media-start-basis", "global");
|
|
clip.setAttribute("data-duration", "0.4");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(4.5);
|
|
expect(armed(clip)).toEqual([true]);
|
|
});
|
|
|
|
it("keeps the clips due at the loop start loaded while the loop plays", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { now, next, afterNext } = tracks(mountRoot());
|
|
window.__timelines = { main: pausedTimeline(20) };
|
|
|
|
await evaluateRuntime();
|
|
window.__hf?.setLoopStart?.(0);
|
|
window.__player?.play();
|
|
window.__player?.seek(19.5, { keepPlaying: true });
|
|
expect(armed(now, next, afterNext)).toEqual([true, true, false]);
|
|
expect(loadsOf(now, next)).toEqual([1, 1]);
|
|
window.__hf?.setLoopStart?.(null);
|
|
window.__player?.seek(19.6, { keepPlaying: true });
|
|
expect(armed(now)).toEqual([false]);
|
|
});
|
|
|
|
it("holds the loop start only while playing, and for a loop start that is a time", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { now } = tracks(mountRoot());
|
|
|
|
await evaluateRuntime();
|
|
window.__hf?.setLoopStart?.(0);
|
|
window.__player?.play();
|
|
window.__player?.seek(9.5, { keepPlaying: true });
|
|
window.__player?.pause();
|
|
expect(armed(now)).toEqual([false]);
|
|
window.__hf?.setLoopStart?.(-1);
|
|
window.__player?.play();
|
|
expect(armed(now)).toEqual([false]);
|
|
});
|
|
|
|
it("never arms a next clip with no authored length, so it is not reloaded", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next } = tracks(mountRoot());
|
|
next.removeAttribute("data-duration");
|
|
|
|
await evaluateRuntime();
|
|
const atBind = loadsOf(next)[0];
|
|
window.__player?.play();
|
|
expect(loadsOf(next)).toEqual([atBind]);
|
|
});
|
|
|
|
it("keeps an armed next clip through a window pass that finds it outside the look-ahead", async () => {
|
|
spyLoad();
|
|
servePreview();
|
|
const { next } = tracks(mountRoot());
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.play();
|
|
window.__player?.seek(0.5, { keepPlaying: true });
|
|
expect(armed(next)).toEqual([true]);
|
|
expect(loadsOf(next)).toEqual([1]);
|
|
});
|
|
|
|
it("gives each parsed clip after the first frame preload none before it can fetch", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
await evaluateRuntime();
|
|
const [first, later] = videos(root, "0", "5");
|
|
const untrimmed = timed(root, "audio", "5");
|
|
untrimmed.removeAttribute("data-duration");
|
|
await Promise.resolve();
|
|
expect([first.preload, later.preload, untrimmed.preload]).toEqual(["", "none", ""]);
|
|
});
|
|
|
|
it("gives each parsed clip preload none in the player's frame, where no clip fetches ahead of the first take", async () => {
|
|
const frame = document.createElement("iframe");
|
|
frame.setAttribute(PLAYER_FRAME_ATTR, "");
|
|
vi.spyOn(window, "frameElement", "get").mockReturnValue(frame);
|
|
asSafari();
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
await evaluateRuntime();
|
|
const [first, later] = videos(root, "0", "5");
|
|
await Promise.resolve();
|
|
expect([first.preload, later.preload]).toEqual(["", "none"]);
|
|
});
|
|
|
|
it("leaves Chromium loading every clip at parse in the player's frame", async () => {
|
|
const frame = document.createElement("iframe");
|
|
frame.setAttribute(PLAYER_FRAME_ATTR, "");
|
|
vi.spyOn(window, "frameElement", "get").mockReturnValue(frame);
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
await evaluateRuntime();
|
|
const [first, later] = videos(root, "0", "5");
|
|
await Promise.resolve();
|
|
expect([first.preload, later.preload]).toEqual(["", ""]);
|
|
});
|
|
|
|
it("leaves a frame the player did not mark loading every clip at parse", async () => {
|
|
vi.spyOn(window, "frameElement", "get").mockReturnValue(document.createElement("iframe"));
|
|
asSafari();
|
|
const root = mountRoot();
|
|
timed(root, "div", "0");
|
|
await evaluateRuntime();
|
|
const [first, later] = videos(root, "0", "5");
|
|
await Promise.resolve();
|
|
expect([first.preload, later.preload]).toEqual(["", ""]);
|
|
});
|
|
|
|
it("defers a clip at 0 inside a later scene, by its start in the film", async () => {
|
|
servePreview();
|
|
const root = mountRoot();
|
|
const opening = timed(root, "div", "0");
|
|
const later = timed(root, "div", "5");
|
|
for (const scene of [opening, later]) {
|
|
scene.setAttribute("data-composition-id", "scene");
|
|
timed(scene, "div", "0");
|
|
}
|
|
await evaluateRuntime();
|
|
const [now, due] = [timed(opening, "video", "0"), timed(later, "video", "0")];
|
|
await Promise.resolve();
|
|
expect([now.preload, due.preload]).toEqual(["", "none"]);
|
|
});
|
|
|
|
it("keeps a render loading every media element at bind", async () => {
|
|
spyLoad();
|
|
const [now, soon, later] = videos(mountRoot(), "0", "1.5", "5");
|
|
|
|
await evaluateRuntime();
|
|
window.__player?.seek(3.6);
|
|
expect(armed(now, soon, later)).toEqual([true, true, true]);
|
|
expect(loadsOf(now, soon, later)).toEqual([1, 1, 1]);
|
|
const parsedLater = timed(document.body, "video", "5");
|
|
await Promise.resolve();
|
|
expect(parsedLater.preload).not.toBe("none");
|
|
expect((window as { __hfMediaDeferral?: unknown }).__hfMediaDeferral).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|