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
24 changes: 24 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6583,6 +6583,30 @@ describe("initSandboxRuntimeModular", () => {
expect(seeks.filter((t) => t > 0.1)).toEqual([]);
});
});
describe("a video the browser never starts loading", () => {
it("moves the film on past it after the wait and names it", async () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame =
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const video = mountAudio("/assets/clip.mp4", { muted: "" }, "video");
// What Safari reports for a video it never requested: loading, with no data and no bytes.
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
Object.defineProperty(video, "networkState", { value: 2, configurable: true });

await startPlayback();
for (let frame = 0; frame < 60 * 7; frame++) raf.step(1000 / 60);

expect(window.__player?.getTime()).toBeGreaterThan(1);
expect(
warn.mock.calls.some(([line]) =>
String(line).includes('clip.mp4": the browser never started loading it'),
),
).toBe(true);
});
});
describe("an audible <video> routed through Web Audio", () => {
const ctx = useMockAudioContext();

Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,15 +1053,19 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.play).not.toHaveBeenCalled();
});

function askedToPlayAtHeader(networkState: number, play?: () => Promise<void>) {
function askedToPlayAtHeader(
networkState: number,
play?: () => Promise<void>,
readyState: number = HTMLMediaElement.HAVE_METADATA,
) {
vi.useFakeTimers({ toFake: ["performance"] });
onTestFinished(() => vi.useRealTimers());
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
onTestFinished(() => warn.mockRestore());
const clip = createMockClip({ start: 0, end: 10 });
clip.el.src = "/alpha.webm";
if (play) clip.el.play = vi.fn(play);
Object.defineProperty(clip.el, "readyState", { value: 1, configurable: true });
Object.defineProperty(clip.el, "readyState", { value: readyState, configurable: true });
Object.defineProperty(clip.el, "networkState", { value: networkState, configurable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1 });
expect(clip.el.play).toHaveBeenCalledTimes(1);
Expand All @@ -1086,6 +1090,34 @@ describe("syncRuntimeMedia", () => {
expect(warn).not.toHaveBeenCalled();
});

it("skips and names a clip the browser never started loading 5 s after it was asked to play", () => {
const { el, warn } = askedToPlayAtHeader(
HTMLMediaElement.NETWORK_LOADING,
undefined,
HTMLMediaElement.HAVE_NOTHING,
);
vi.advanceTimersByTime(4999);
expect(isUnplayable(el)).toBe(false);
vi.advanceTimersByTime(1);
expect(isUnplayable(el)).toBe(true);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain(
'alpha.webm": the browser never started loading it',
);
});

it("keeps waiting on a clip with no data yet whose bytes are arriving", () => {
const { el, warn } = askedToPlayAtHeader(
HTMLMediaElement.NETWORK_LOADING,
undefined,
HTMLMediaElement.HAVE_NOTHING,
);
el.dispatchEvent(new Event("progress"));
vi.advanceTimersByTime(60_000);
expect(isUnplayable(el)).toBe(false);
expect(warn).not.toHaveBeenCalled();
});

it("keeps waiting on a clip at its header whose play() the browser refused", async () => {
const refused = () => Promise.reject(new DOMException("needs a tap", "NotAllowedError"));
const { el, warn } = askedToPlayAtHeader(HTMLMediaElement.NETWORK_IDLE, refused);
Expand Down
43 changes: 29 additions & 14 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,45 +231,60 @@ export function primeMediaForLaterStarts(els: Iterable<HTMLMediaElement>): void
// HTMLMediaElement.NETWORK_NO_SOURCE — no usable source (404 / unsupported).
const MEDIA_NETWORK_NO_SOURCE = 3;
const MEDIA_NETWORK_IDLE = 1;
const MEDIA_NETWORK_LOADING = 2;
const MEDIA_HAVE_NOTHING = 0;
const MEDIA_HAVE_METADATA = 1;
const STOPPED_AT_METADATA_MS = 5000;
const firstPlayAskedAt = new WeakMap<HTMLMediaElement, number | null>();
const watchedForFirstFrame = new WeakSet<HTMLMediaElement>();
const warnedSkipped = new WeakSet<HTMLMediaElement>();
const receivedBytes = new WeakSet<HTMLMediaElement>();

const sourceOf = (el: HTMLMediaElement): string => el.currentSrc || el.src;

function noteFirstPlayAsk(el: HTMLMediaElement): void {
if (!watchedForFirstFrame.has(el)) {
watchedForFirstFrame.add(el);
el.addEventListener("loadeddata", () => firstPlayAskedAt.set(el, null));
el.addEventListener("emptied", () => firstPlayAskedAt.delete(el));
el.addEventListener("emptied", () => (firstPlayAskedAt.delete(el), receivedBytes.delete(el)));
el.addEventListener("progress", () => receivedBytes.add(el));
}
if (!firstPlayAskedAt.has(el)) {
firstPlayAskedAt.set(el, el.readyState > MEDIA_HAVE_METADATA ? null : performance.now());
}
}

function stoppedAtMetadata(el: HTMLMediaElement): boolean {
type Stall = "after its header" | "never started";

// Safari can leave a video "loading" with no request ever sent; a slow fetch still delivers bytes (progress).
function stallSinceFirstAsk(el: HTMLMediaElement): Stall | null {
const askedAt = firstPlayAskedAt.get(el);
return (
askedAt != null &&
performance.now() - askedAt >= STOPPED_AT_METADATA_MS &&
el.readyState === MEDIA_HAVE_METADATA &&
el.networkState === MEDIA_NETWORK_IDLE
);
if (askedAt == null || performance.now() - askedAt < STOPPED_AT_METADATA_MS) return null;
if (el.readyState === MEDIA_HAVE_METADATA && el.networkState === MEDIA_NETWORK_IDLE)
return "after its header";
if (
el.readyState === MEDIA_HAVE_NOTHING &&
el.networkState === MEDIA_NETWORK_LOADING &&
!receivedBytes.has(el)
)
return "never started";
return null;
}

// Errored, sourceless, or stopped loading at its header since first asked to play: it can't play, and
// re-issuing play() every tick just floods rejections. Skip it until its state changes (src reload).
const SKIP_REASON: Record<Stall, string> = {
"after its header": "the browser stopped loading it after its header",
"never started": "the browser never started loading it",
};

// Errored, sourceless, or stalled since first asked to play: it can't play, and re-issuing play()
// every tick just floods rejections. Skip it until its state changes (src reload).
export function isUnplayable(el: HTMLMediaElement): boolean {
if (el.error != null || el.networkState === MEDIA_NETWORK_NO_SOURCE) return true;
if (!stoppedAtMetadata(el)) return false;
const stall = stallSinceFirstAsk(el);
if (!stall) return false;
if (!warnedSkipped.has(el)) {
warnedSkipped.add(el);
console.warn(
`[hyperframes] skipping "${sourceOf(el)}": the browser stopped loading it after its header; the rest plays`,
);
console.warn(`[hyperframes] skipping "${sourceOf(el)}": ${SKIP_REASON[stall]}; the rest plays`);
}
return true;
}
Expand Down
Loading