mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): add LRU eviction to media preloader, protect untimed media
Three root-cause fixes for the lazy media preloading feature: 1. Untimed media orphaned at preload="metadata": the else branch in bindMediaMetadataListeners demoted ALL media elements, but the mediaPreloader only manages timed clips (data-start). Untimed media (background audio, ambient loops) got stuck at metadata forever. Now only timed elements are demoted. 2. Monotonic promotion with no eviction: once promoted, clips stayed at preload="auto" forever. Scrubbing through the full timeline promoted everything, bringing back the OOM crash. Added LRU eviction with MAX_PROMOTED=5 — when clips leave the preload window, their src is cleared and load() called to release buffered data per MDN. On re-entry, the original src is restored. 3. Metadata preload without load(): setting preload="metadata" alone doesn't guarantee the metadata fetch in Chrome Lite mode or Firefox with media.preload.default=0. Now load() is called after demotion to ensure el.duration is populated for timeline computation. Also adds exact-boundary tests for LAZY_THRESHOLD=6 and eviction coverage (evict on scrub, src restoration, MAX_PROMOTED cap, load() called on eviction).
This commit is contained in:
@@ -1252,10 +1252,18 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 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) {
|
for (const mediaEl of mediaEls) {
|
||||||
if (!mediaEl.hasAttribute("data-start")) continue;
|
if (!mediaEl.hasAttribute("data-start")) continue;
|
||||||
if (mediaEl.preload === "auto" || mediaEl.preload === "") {
|
if (mediaEl.preload === "auto" || mediaEl.preload === "") {
|
||||||
mediaEl.preload = "metadata";
|
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) {
|
if (mediaEl.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||||
mediaEl.load();
|
mediaEl.load();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { createMediaPreloadManager } from "./mediaPreloader";
|
import { createMediaPreloadManager } from "./mediaPreloader";
|
||||||
|
|
||||||
function mockMediaElement(attrs: {
|
function mockMediaElement(attrs: {
|
||||||
@@ -13,6 +13,7 @@ function mockMediaElement(attrs: {
|
|||||||
duration: Number.NaN,
|
duration: Number.NaN,
|
||||||
defaultPlaybackRate: 1,
|
defaultPlaybackRate: 1,
|
||||||
loop: false,
|
loop: false,
|
||||||
|
src: `blob:mock-${attrs.start}`,
|
||||||
dataset: {
|
dataset: {
|
||||||
start: attrs.start,
|
start: attrs.start,
|
||||||
duration: attrs.duration,
|
duration: attrs.duration,
|
||||||
@@ -23,8 +24,13 @@ function mockMediaElement(attrs: {
|
|||||||
if (name === "data-duration") return attrs.duration ?? null;
|
if (name === "data-duration") return attrs.duration ?? null;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
removeAttribute: (name: string) => {
|
||||||
|
if (name === "src") {
|
||||||
|
(el as Record<string, unknown>).src = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
closest: () => null,
|
closest: () => null,
|
||||||
load: () => {},
|
load: vi.fn(),
|
||||||
} as unknown as HTMLMediaElement;
|
} as unknown as HTMLMediaElement;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,31 @@ describe("createMediaPreloadManager", () => {
|
|||||||
expect(manager.isLazy()).toBe(false);
|
expect(manager.isLazy()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("activates lazy mode with 6+ media elements", () => {
|
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) =>
|
elements = Array.from({ length: 8 }, (_, i) =>
|
||||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||||
);
|
);
|
||||||
@@ -141,4 +171,101 @@ describe("createMediaPreloadManager", () => {
|
|||||||
const promotedCount = elements.filter((el) => el.preload === "auto").length;
|
const promotedCount = elements.filter((el) => el.preload === "auto").length;
|
||||||
expect(promotedCount).toBeGreaterThanOrEqual(2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
|
|||||||
const LAZY_THRESHOLD = 6;
|
const LAZY_THRESHOLD = 6;
|
||||||
const LOOKAHEAD_SECONDS = 10;
|
const LOOKAHEAD_SECONDS = 10;
|
||||||
const LOOKAHEAD_MIN_CLIPS = 2;
|
const LOOKAHEAD_MIN_CLIPS = 2;
|
||||||
|
const MAX_PROMOTED = 5;
|
||||||
|
|
||||||
export interface MediaPreloadManager {
|
export interface MediaPreloadManager {
|
||||||
refresh(): void;
|
refresh(): void;
|
||||||
@@ -17,7 +18,11 @@ export function createMediaPreloadManager(options?: {
|
|||||||
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
|
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
|
||||||
}): MediaPreloadManager {
|
}): MediaPreloadManager {
|
||||||
let clips: RuntimeMediaClip[] = [];
|
let clips: RuntimeMediaClip[] = [];
|
||||||
const promoted = new WeakSet<HTMLMediaElement>();
|
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 lazy = false;
|
||||||
|
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
@@ -26,9 +31,34 @@ export function createMediaPreloadManager(options?: {
|
|||||||
lazy = clips.length >= LAZY_THRESHOLD;
|
lazy = clips.length >= LAZY_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function promoteClip(clip: RuntimeMediaClip): void {
|
||||||
if (promoted.has(clip.el)) return;
|
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);
|
promoted.add(clip.el);
|
||||||
|
promotionOrder.push(clip.el);
|
||||||
|
|
||||||
if (clip.el.preload !== "auto") {
|
if (clip.el.preload !== "auto") {
|
||||||
clip.el.preload = "auto";
|
clip.el.preload = "auto";
|
||||||
}
|
}
|
||||||
@@ -37,6 +67,35 @@ export function createMediaPreloadManager(options?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
|
||||||
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
|
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
|
||||||
const inWindow = new Set<RuntimeMediaClip>();
|
const inWindow = new Set<RuntimeMediaClip>();
|
||||||
@@ -62,9 +121,9 @@ export function createMediaPreloadManager(options?: {
|
|||||||
return inWindow;
|
return inWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sync(currentTimeSeconds: number): void {
|
function syncWindow(timeSeconds: number): void {
|
||||||
if (!lazy) return;
|
const window = getClipsInWindow(timeSeconds);
|
||||||
const window = getClipsInWindow(currentTimeSeconds);
|
evictOutsideWindow(window);
|
||||||
for (const clip of clips) {
|
for (const clip of clips) {
|
||||||
if (window.has(clip)) {
|
if (window.has(clip)) {
|
||||||
promoteClip(clip);
|
promoteClip(clip);
|
||||||
@@ -72,14 +131,14 @@ export function createMediaPreloadManager(options?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sync(currentTimeSeconds: number): void {
|
||||||
|
if (!lazy) return;
|
||||||
|
syncWindow(currentTimeSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
function preloadAroundTime(timeSeconds: number): void {
|
function preloadAroundTime(timeSeconds: number): void {
|
||||||
if (!lazy) return;
|
if (!lazy) return;
|
||||||
const window = getClipsInWindow(timeSeconds);
|
syncWindow(timeSeconds);
|
||||||
for (const clip of clips) {
|
|
||||||
if (window.has(clip)) {
|
|
||||||
promoteClip(clip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLazy(): boolean {
|
function isLazy(): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user