From 0c6f438ae7cdf7046b56bb1eb8a0d8288422a360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 11 May 2026 18:30:49 -0700 Subject: [PATCH] fix(core): add LRU eviction to media preloader, protect untimed media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/core/src/runtime/init.ts | 8 ++ .../core/src/runtime/mediaPreloader.test.ts | 133 +++++++++++++++++- packages/core/src/runtime/mediaPreloader.ts | 79 +++++++++-- 3 files changed, 207 insertions(+), 13 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 036d56f2d..cdafb80bf 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1252,10 +1252,18 @@ export function initSandboxRuntimeModular(): void { } } } 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) { if (!mediaEl.hasAttribute("data-start")) 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(); diff --git a/packages/core/src/runtime/mediaPreloader.test.ts b/packages/core/src/runtime/mediaPreloader.test.ts index bb699e72d..2494814b5 100644 --- a/packages/core/src/runtime/mediaPreloader.test.ts +++ b/packages/core/src/runtime/mediaPreloader.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { createMediaPreloadManager } from "./mediaPreloader"; function mockMediaElement(attrs: { @@ -13,6 +13,7 @@ function mockMediaElement(attrs: { duration: Number.NaN, defaultPlaybackRate: 1, loop: false, + src: `blob:mock-${attrs.start}`, dataset: { start: attrs.start, duration: attrs.duration, @@ -23,8 +24,13 @@ function mockMediaElement(attrs: { if (name === "data-duration") return attrs.duration ?? null; return null; }, + removeAttribute: (name: string) => { + if (name === "src") { + (el as Record).src = ""; + } + }, closest: () => null, - load: () => {}, + load: vi.fn(), } as unknown as HTMLMediaElement; return el; } @@ -57,7 +63,31 @@ describe("createMediaPreloadManager", () => { 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) => mockMediaElement({ start: String(i * 5), duration: "5" }), ); @@ -141,4 +171,101 @@ describe("createMediaPreloadManager", () => { 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).mock.calls.length; + + // Scrub away — eviction should call load() to release buffers + manager.sync(40); + const loadCallsAfter = (elements[0].load as ReturnType).mock.calls.length; + expect(loadCallsAfter).toBeGreaterThan(loadCallsBefore); + }); }); diff --git a/packages/core/src/runtime/mediaPreloader.ts b/packages/core/src/runtime/mediaPreloader.ts index 05fe09a7a..ce004321a 100644 --- a/packages/core/src/runtime/mediaPreloader.ts +++ b/packages/core/src/runtime/mediaPreloader.ts @@ -3,6 +3,7 @@ import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media"; const LAZY_THRESHOLD = 6; const LOOKAHEAD_SECONDS = 10; const LOOKAHEAD_MIN_CLIPS = 2; +const MAX_PROMOTED = 5; export interface MediaPreloadManager { refresh(): void; @@ -17,7 +18,11 @@ export function createMediaPreloadManager(options?: { shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; }): MediaPreloadManager { let clips: RuntimeMediaClip[] = []; - const promoted = new WeakSet(); + const promoted = new Set(); + /** Insertion-order queue for LRU eviction (oldest first). */ + const promotionOrder: HTMLMediaElement[] = []; + /** Stashed original src so we can restore after eviction. */ + const originalSrc = new Map(); let lazy = false; function refresh(): void { @@ -26,9 +31,34 @@ export function createMediaPreloadManager(options?: { 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 { 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"; } @@ -37,6 +67,35 @@ export function createMediaPreloadManager(options?: { } } + function evictOutsideWindow(inWindow: Set): void { + const windowEls = new Set(); + 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 { const windowEnd = timeSeconds + LOOKAHEAD_SECONDS; const inWindow = new Set(); @@ -62,9 +121,9 @@ export function createMediaPreloadManager(options?: { return inWindow; } - function sync(currentTimeSeconds: number): void { - if (!lazy) return; - const window = getClipsInWindow(currentTimeSeconds); + function syncWindow(timeSeconds: number): void { + const window = getClipsInWindow(timeSeconds); + evictOutsideWindow(window); for (const clip of clips) { if (window.has(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 { if (!lazy) return; - const window = getClipsInWindow(timeSeconds); - for (const clip of clips) { - if (window.has(clip)) { - promoteClip(clip); - } - } + syncWindow(timeSeconds); } function isLazy(): boolean {