Merge pull request #729 from heygen-com/feat/lazy-media-preloading

feat(core): lazy media preloading for heavy compositions
This commit is contained in:
Miguel Ángel
2026-05-12 06:48:29 +02:00
committed by GitHub
6 changed files with 620 additions and 11 deletions
+50 -7
View File
@@ -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);
});
});
+167
View File
@@ -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"],
});
}
});
+32 -2
View File
@@ -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;
}
}