mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
Merge pull request #729 from heygen-com/feat/lazy-media-preloading
feat(core): lazy media preloading for heavy compositions
This commit is contained in:
@@ -7,6 +7,7 @@ import { createLottieAdapter } from "./adapters/lottie";
|
||||
import { createThreeAdapter } from "./adapters/three";
|
||||
import { createWaapiAdapter } from "./adapters/waapi";
|
||||
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
|
||||
import { createMediaPreloadManager } from "./mediaPreloader";
|
||||
import { createPickerModule } from "./picker";
|
||||
import { createRuntimePlayer } from "./player";
|
||||
import { createRuntimeState } from "./state";
|
||||
@@ -1222,24 +1223,61 @@ export function initSandboxRuntimeModular(): void {
|
||||
metadataBoundMedia.clear();
|
||||
};
|
||||
|
||||
const isRenderMode = Boolean((window as Record<string, unknown>).__HF_EXPORT_RENDER_SEEK_CONFIG);
|
||||
const mediaPreloader = createMediaPreloadManager({
|
||||
onActivation: (clipCount) => {
|
||||
postRuntimeDiagnosticOnce("lazy_preload_activated", { clipCount }, "lazy_preload_activated");
|
||||
},
|
||||
});
|
||||
|
||||
const bindMediaMetadataListeners = () => {
|
||||
if (state.tornDown) return;
|
||||
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as HTMLMediaElement[];
|
||||
const isLazy = mediaPreloader.isLazy();
|
||||
|
||||
let newElementsBound = false;
|
||||
for (const mediaEl of mediaEls) {
|
||||
if (metadataBoundMedia.has(mediaEl)) continue;
|
||||
metadataBoundMedia.add(mediaEl);
|
||||
newElementsBound = true;
|
||||
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
|
||||
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
|
||||
|
||||
// Eagerly preload media data so audio/video is buffered before the user
|
||||
// clicks play. Without this, the first play() call fires on un-fetched
|
||||
// media, producing silence or choppy audio until the browser caches it.
|
||||
if (mediaEl.preload !== "auto") {
|
||||
mediaEl.preload = "auto";
|
||||
// In eager mode, preload inline (same ordering as before lazy preloading)
|
||||
if (!isLazy || isRenderMode) {
|
||||
if (mediaEl.preload !== "auto") mediaEl.preload = "auto";
|
||||
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) mediaEl.load();
|
||||
}
|
||||
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
mediaEl.load();
|
||||
}
|
||||
|
||||
if (newElementsBound && !isRenderMode) {
|
||||
mediaPreloader.refresh();
|
||||
}
|
||||
|
||||
// Lazy-mode demotion runs separately after refresh updates the clip list
|
||||
if (mediaPreloader.isLazy() && !isRenderMode) {
|
||||
// Only demote timed media (elements with data-start) to metadata preload.
|
||||
// Untimed media (background audio, ambient loops, decorative video) must
|
||||
// keep their original preload state — the mediaPreloader only manages
|
||||
// timed clips and would never promote them back.
|
||||
for (const mediaEl of mediaEls) {
|
||||
if (!mediaEl.hasAttribute("data-start")) continue;
|
||||
// Power-user opt-out: data-preload-eager keeps a clip eagerly buffered
|
||||
// even under lazy mode, useful when a specific clip must be instantly
|
||||
// available regardless of playhead proximity.
|
||||
if (mediaEl.hasAttribute("data-preload-eager")) continue;
|
||||
if (mediaEl.preload === "auto" || mediaEl.preload === "") {
|
||||
mediaEl.preload = "metadata";
|
||||
// Kick off the metadata fetch explicitly — some browsers (Chrome Lite
|
||||
// mode, Firefox with media.preload.default=0) won't fetch metadata
|
||||
// until load() is called, and timeline duration depends on el.duration.
|
||||
mediaEl.load();
|
||||
}
|
||||
if (mediaEl.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||
mediaEl.load();
|
||||
}
|
||||
}
|
||||
mediaPreloader.preloadAroundTime(Math.max(0, state.currentTime || 0));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1787,6 +1825,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
if (clock.isPlaying()) {
|
||||
syncMediaForCurrentState();
|
||||
if (mediaPreloader.isLazy() && transportTickCount % 10 === 0) {
|
||||
mediaPreloader.sync(Math.max(0, state.currentTime || 0));
|
||||
}
|
||||
}
|
||||
postState(false);
|
||||
} finally {
|
||||
@@ -1821,6 +1862,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
player.play = () => {
|
||||
const tl = state.capturedTimeline;
|
||||
if (!tl || clock.isPlaying()) return;
|
||||
mediaPreloader.preloadAroundTime(Math.max(0, state.currentTime || 0));
|
||||
const dur = getSafeTimelineDurationSeconds(tl, 0);
|
||||
if (dur > 0) {
|
||||
clock.setDuration(dur);
|
||||
@@ -1890,6 +1932,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
mediaPreloader.preloadAroundTime(quantized);
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
const wasPlaying = clock.isPlaying();
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { createMediaPreloadManager } from "./mediaPreloader";
|
||||
|
||||
function mockMediaElement(attrs: {
|
||||
start: string;
|
||||
duration?: string;
|
||||
tag?: string;
|
||||
}): HTMLMediaElement {
|
||||
const el = {
|
||||
tagName: (attrs.tag ?? "VIDEO").toUpperCase(),
|
||||
preload: "auto",
|
||||
readyState: 0,
|
||||
duration: Number.NaN,
|
||||
defaultPlaybackRate: 1,
|
||||
loop: false,
|
||||
src: `blob:mock-${attrs.start}`,
|
||||
dataset: {
|
||||
start: attrs.start,
|
||||
duration: attrs.duration,
|
||||
},
|
||||
hasAttribute: (name: string) => name === "data-start",
|
||||
getAttribute: (name: string) => {
|
||||
if (name === "data-start") return attrs.start;
|
||||
if (name === "data-duration") return attrs.duration ?? null;
|
||||
return null;
|
||||
},
|
||||
removeAttribute: (name: string) => {
|
||||
if (name === "src") {
|
||||
(el as Record<string, unknown>).src = "";
|
||||
}
|
||||
},
|
||||
closest: () => null,
|
||||
load: vi.fn(),
|
||||
} as unknown as HTMLMediaElement;
|
||||
return el;
|
||||
}
|
||||
|
||||
function setupDOM(elements: HTMLMediaElement[]): void {
|
||||
const originalQuerySelector = document.querySelectorAll.bind(document);
|
||||
document.querySelectorAll = ((selector: string) => {
|
||||
if (selector === "video, audio") return elements as unknown as NodeListOf<Element>;
|
||||
return originalQuerySelector(selector);
|
||||
}) as typeof document.querySelectorAll;
|
||||
}
|
||||
|
||||
describe("createMediaPreloadManager", () => {
|
||||
let elements: HTMLMediaElement[];
|
||||
|
||||
beforeEach(() => {
|
||||
elements = [];
|
||||
});
|
||||
|
||||
it("is not lazy when fewer than 6 media elements", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
];
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates lazy mode at exactly LAZY_THRESHOLD (6 elements)", () => {
|
||||
elements = Array.from({ length: 6 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not lazy with 5 elements (below threshold)", () => {
|
||||
elements = Array.from({ length: 5 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates lazy mode with 8 media elements", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("sync promotes clips in the lookahead window", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.sync(0);
|
||||
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
expect(elements[1].preload).toBe("auto");
|
||||
expect(elements[7].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("preloadAroundTime promotes clips near seek target", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.preloadAroundTime(30);
|
||||
|
||||
expect(elements[6].preload).toBe("auto");
|
||||
expect(elements[7].preload).toBe("auto");
|
||||
expect(elements[0].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("sync is a no-op when not lazy", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
];
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
manager.sync(0);
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("guarantees at least LOOKAHEAD_MIN_CLIPS are promoted", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 20), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.sync(0);
|
||||
|
||||
const promotedCount = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(promotedCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("evicts clips when scrubbing away from them", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
// Promote clips around t=0
|
||||
manager.sync(0);
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
expect(elements[1].preload).toBe("auto");
|
||||
|
||||
// Scrub to t=40 — clips 0,1 should be evicted
|
||||
manager.sync(40);
|
||||
expect(elements[0].preload).toBe("metadata");
|
||||
expect(elements[0].src).toBe("");
|
||||
expect(elements[8].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("restores src when re-promoting a previously evicted clip", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
const originalSrc0 = elements[0].src;
|
||||
|
||||
// Promote at t=0, scrub away, scrub back
|
||||
manager.sync(0);
|
||||
manager.sync(40);
|
||||
expect(elements[0].src).toBe("");
|
||||
|
||||
manager.sync(0);
|
||||
expect(elements[0].src).toBe(originalSrc0);
|
||||
expect(elements[0].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("does not exceed MAX_PROMOTED (5) clips", () => {
|
||||
// 10 clips, each 5s long, spaced 5s apart
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
// Sync at t=0 — window covers clips 0,1,2 (0-15s lookahead)
|
||||
manager.sync(0);
|
||||
const promotedAfterFirst = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(promotedAfterFirst).toBeLessThanOrEqual(5);
|
||||
|
||||
// Sync at different position — should evict old ones
|
||||
manager.sync(25);
|
||||
const totalPromoted = elements.filter((el) => el.preload === "auto").length;
|
||||
expect(totalPromoted).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("calls load() when evicting to release buffers", () => {
|
||||
elements = Array.from({ length: 10 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
for (const el of elements) {
|
||||
el.preload = "metadata";
|
||||
}
|
||||
|
||||
manager.sync(0);
|
||||
const loadCallsBefore = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
// Scrub away — eviction should call load() to release buffers
|
||||
manager.sync(40);
|
||||
const loadCallsAfter = (elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(loadCallsAfter).toBeGreaterThan(loadCallsBefore);
|
||||
});
|
||||
|
||||
it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => {
|
||||
elements = Array.from({ length: 6 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onActivation when lazy mode activates", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
manager.refresh();
|
||||
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
expect(onActivation).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
it("does not call onActivation below threshold", () => {
|
||||
elements = [
|
||||
mockMediaElement({ start: "0", duration: "5" }),
|
||||
mockMediaElement({ start: "5", duration: "5" }),
|
||||
];
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
manager.refresh();
|
||||
|
||||
expect(onActivation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onActivation only once across multiple refreshes", () => {
|
||||
elements = Array.from({ length: 8 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
const onActivation = vi.fn();
|
||||
const manager = createMediaPreloadManager({ onActivation });
|
||||
manager.refresh();
|
||||
manager.refresh();
|
||||
manager.refresh();
|
||||
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
|
||||
elements = Array.from({ length: 4 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
// 4 elements is below the default threshold (6) but at our custom one
|
||||
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 4;
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
|
||||
// Clean up
|
||||
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
|
||||
});
|
||||
|
||||
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
|
||||
elements = Array.from({ length: 4 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
// Ensure it's not set
|
||||
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
|
||||
|
||||
// Compositions with fewer than 6 timed clips rarely exceed browser memory
|
||||
// limits during eager preload. The threshold avoids preload management
|
||||
// overhead for typical compositions while catching the heavy-media case
|
||||
// (e.g., 20 clips / 6GB reported in heygen-com/hyperframes#729).
|
||||
const LAZY_THRESHOLD = 6;
|
||||
const LOOKAHEAD_SECONDS = 10;
|
||||
const LOOKAHEAD_MIN_CLIPS = 2;
|
||||
// Cap on simultaneously promoted (buffered) clips. When the lookahead window
|
||||
// contains more clips than this (e.g., many short clips), all window clips
|
||||
// stay promoted — the cap is defense-in-depth, not a hard ceiling. The primary
|
||||
// memory bound comes from window-based eviction in syncWindow().
|
||||
const MAX_PROMOTED = 5;
|
||||
|
||||
export interface MediaPreloadManager {
|
||||
refresh(): void;
|
||||
sync(currentTimeSeconds: number): void;
|
||||
preloadAroundTime(timeSeconds: number): void;
|
||||
isLazy(): boolean;
|
||||
}
|
||||
|
||||
export function createMediaPreloadManager(options?: {
|
||||
resolveStartSeconds?: (element: Element) => number;
|
||||
resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null;
|
||||
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
|
||||
onActivation?: (clipCount: number) => void;
|
||||
}): MediaPreloadManager {
|
||||
let clips: RuntimeMediaClip[] = [];
|
||||
const promoted = new Set<HTMLMediaElement>();
|
||||
/** Insertion-order queue for LRU eviction (oldest first). */
|
||||
const promotionOrder: HTMLMediaElement[] = [];
|
||||
/** Stashed original src so we can restore after eviction. */
|
||||
const originalSrc = new Map<HTMLMediaElement, string>();
|
||||
let lazy = false;
|
||||
let activationEmitted = false;
|
||||
|
||||
function refresh(): void {
|
||||
const cache = refreshRuntimeMediaCache(options);
|
||||
clips = cache.mediaClips;
|
||||
const configuredThreshold =
|
||||
typeof (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD === "number"
|
||||
? ((window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD as number)
|
||||
: LAZY_THRESHOLD;
|
||||
lazy = clips.length >= configuredThreshold;
|
||||
if (lazy && !activationEmitted) {
|
||||
activationEmitted = true;
|
||||
options?.onActivation?.(clips.length);
|
||||
}
|
||||
}
|
||||
|
||||
function evictClip(clip: RuntimeMediaClip): void {
|
||||
if (!promoted.has(clip.el)) return;
|
||||
// Stash original src before clearing
|
||||
if (!originalSrc.has(clip.el)) {
|
||||
originalSrc.set(clip.el, clip.el.src);
|
||||
}
|
||||
// Release buffered data: only way to free memory per MDN
|
||||
clip.el.removeAttribute("src");
|
||||
clip.el.load();
|
||||
clip.el.preload = "metadata";
|
||||
promoted.delete(clip.el);
|
||||
const idx = promotionOrder.indexOf(clip.el);
|
||||
if (idx !== -1) promotionOrder.splice(idx, 1);
|
||||
}
|
||||
|
||||
function promoteClip(clip: RuntimeMediaClip): void {
|
||||
if (promoted.has(clip.el)) return;
|
||||
|
||||
// Restore src if previously evicted
|
||||
const stashedSrc = originalSrc.get(clip.el);
|
||||
if (stashedSrc !== undefined && !clip.el.src) {
|
||||
clip.el.src = stashedSrc;
|
||||
originalSrc.delete(clip.el);
|
||||
}
|
||||
|
||||
promoted.add(clip.el);
|
||||
promotionOrder.push(clip.el);
|
||||
|
||||
if (clip.el.preload !== "auto") {
|
||||
clip.el.preload = "auto";
|
||||
}
|
||||
if (clip.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
clip.el.load();
|
||||
}
|
||||
}
|
||||
|
||||
function evictOutsideWindow(inWindow: Set<RuntimeMediaClip>): void {
|
||||
const windowEls = new Set<HTMLMediaElement>();
|
||||
for (const clip of inWindow) {
|
||||
windowEls.add(clip.el);
|
||||
}
|
||||
|
||||
// Evict clips no longer in window, oldest first
|
||||
for (const clip of clips) {
|
||||
if (promoted.has(clip.el) && !windowEls.has(clip.el)) {
|
||||
evictClip(clip);
|
||||
}
|
||||
}
|
||||
|
||||
// If still over budget after removing out-of-window clips,
|
||||
// evict the oldest promoted that isn't in the current window
|
||||
while (promotionOrder.length > MAX_PROMOTED) {
|
||||
const oldest = promotionOrder[0];
|
||||
if (windowEls.has(oldest)) break; // don't evict something currently needed
|
||||
const clip = clips.find((c) => c.el === oldest);
|
||||
if (clip) {
|
||||
evictClip(clip);
|
||||
} else {
|
||||
// Element no longer in clips list, just remove from tracking
|
||||
promoted.delete(oldest);
|
||||
promotionOrder.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
|
||||
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
|
||||
const inWindow = new Set<RuntimeMediaClip>();
|
||||
|
||||
for (const clip of clips) {
|
||||
const active = timeSeconds >= clip.start && timeSeconds < clip.end;
|
||||
const inLookahead = clip.start >= timeSeconds && clip.start <= windowEnd;
|
||||
if (active || inLookahead) {
|
||||
inWindow.add(clip);
|
||||
}
|
||||
}
|
||||
|
||||
if (inWindow.size < LOOKAHEAD_MIN_CLIPS) {
|
||||
const sorted = clips
|
||||
.filter((c) => c.start >= timeSeconds && !inWindow.has(c))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
for (const clip of sorted) {
|
||||
inWindow.add(clip);
|
||||
if (inWindow.size >= LOOKAHEAD_MIN_CLIPS) break;
|
||||
}
|
||||
}
|
||||
|
||||
return inWindow;
|
||||
}
|
||||
|
||||
function syncWindow(timeSeconds: number): void {
|
||||
const window = getClipsInWindow(timeSeconds);
|
||||
evictOutsideWindow(window);
|
||||
for (const clip of clips) {
|
||||
if (window.has(clip)) {
|
||||
promoteClip(clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sync(currentTimeSeconds: number): void {
|
||||
if (!lazy) return;
|
||||
syncWindow(currentTimeSeconds);
|
||||
}
|
||||
|
||||
function preloadAroundTime(timeSeconds: number): void {
|
||||
if (!lazy) return;
|
||||
syncWindow(timeSeconds);
|
||||
}
|
||||
|
||||
function isLazy(): boolean {
|
||||
return lazy;
|
||||
}
|
||||
|
||||
return { refresh, sync, preloadAroundTime, isLazy };
|
||||
}
|
||||
@@ -548,8 +548,15 @@ describe("HyperframesPlayer media MutationObserver scoping", () => {
|
||||
expect(observedTargets).not.toContain(fakeDoc.body);
|
||||
// Subtree is still required — sub-composition media can be deeply nested
|
||||
// inside the host (e.g. wrapper div around the `<audio>`).
|
||||
// Attribute observation on "preload" is required so the player creates
|
||||
// parent proxies just-in-time when the preloader promotes a clip.
|
||||
for (const call of observeSpy.mock.calls) {
|
||||
expect(call[1]).toEqual({ childList: true, subtree: true });
|
||||
expect(call[1]).toEqual({
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["preload"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1498,6 +1498,14 @@ class HyperframesPlayer extends HTMLElement {
|
||||
* identical URL-resolution and attribute parsing.
|
||||
*/
|
||||
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
// Respect the preloader's demotion: if the iframe element has been set to
|
||||
// metadata-only or none, creating a parent proxy with preload="auto" would
|
||||
// bypass the lazy preloader and eagerly buffer the clip. Skip it — the
|
||||
// MutationObserver in _observeDynamicMedia watches for preload attribute
|
||||
// changes and will create the proxy just-in-time when the preloader
|
||||
// promotes the clip.
|
||||
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
|
||||
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
@@ -1544,6 +1552,22 @@ class HyperframesPlayer extends HTMLElement {
|
||||
if (typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
// Attribute mutations: the preloader promotes a clip by changing its
|
||||
// preload attribute from "metadata" to "auto". When that happens, the
|
||||
// early-return guard in _adoptIframeMedia no longer blocks, so we can
|
||||
// create the parent proxy just-in-time.
|
||||
if (m.type === "attributes" && m.attributeName === "preload") {
|
||||
const target = m.target;
|
||||
if (
|
||||
target instanceof HTMLMediaElement &&
|
||||
target.matches("audio[data-start], video[data-start]") &&
|
||||
target.preload === "auto"
|
||||
) {
|
||||
this._adoptIframeMedia(target);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const added of m.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
// Handle both the node itself and any timed media nested inside
|
||||
@@ -1578,13 +1602,19 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
});
|
||||
const observeOpts: MutationObserverInit = {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["preload"],
|
||||
};
|
||||
const hosts = doc.querySelectorAll("[data-composition-id]");
|
||||
if (hosts.length > 0) {
|
||||
for (const host of hosts) {
|
||||
obs.observe(host, { childList: true, subtree: true });
|
||||
obs.observe(host, observeOpts);
|
||||
}
|
||||
} else {
|
||||
obs.observe(doc.body, { childList: true, subtree: true });
|
||||
obs.observe(doc.body, observeOpts);
|
||||
}
|
||||
this._mediaObserver = obs;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
||||
if (!win || !doc) return lastResult;
|
||||
|
||||
for (const el of doc.querySelectorAll("video, audio")) {
|
||||
if (el instanceof HTMLMediaElement && el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
if (
|
||||
el instanceof HTMLMediaElement &&
|
||||
el.preload === "auto" &&
|
||||
el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user