feat(core): lazy media preloading for heavy compositions

Compositions with many large video files (e.g., 6GB across 20 clips) crash
the browser because the runtime eagerly sets preload="auto" + .load() on
every media element at startup. All files buffer simultaneously, exhausting
memory.

Add a MediaPreloadManager that gates preloading based on playhead position:

- Activates when a composition has ≥6 timed media elements
- Only preloads clips within a 10-second lookahead window (or next 2 clips)
- Far-away clips stay at preload="metadata" (resolves duration without
  downloading data)
- Advances the window on each transport tick and immediately on seek
- Render mode (window.__HF_EXPORT_RENDER_SEEK_CONFIG) keeps eager preload
  for deterministic frame capture
- Small compositions (<6 clips) keep eager preload — no behavior change

Studio's hasUnloadedAssets now skips elements with preload!="auto", so
deferred clips don't block the loading overlay.
This commit is contained in:
Miguel Ángel
2026-05-11 18:41:59 -07:00
parent a94ca4f5d3
commit 773c4261e2
2 changed files with 234 additions and 0 deletions
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach } 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,
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;
},
closest: () => null,
load: () => {},
} 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 with 6+ 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);
});
});
@@ -0,0 +1,90 @@
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
const LAZY_THRESHOLD = 6;
const LOOKAHEAD_SECONDS = 10;
const LOOKAHEAD_MIN_CLIPS = 2;
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;
}): MediaPreloadManager {
let clips: RuntimeMediaClip[] = [];
const promoted = new WeakSet<HTMLMediaElement>();
let lazy = false;
function refresh(): void {
const cache = refreshRuntimeMediaCache(options);
clips = cache.mediaClips;
lazy = clips.length >= LAZY_THRESHOLD;
}
function promoteClip(clip: RuntimeMediaClip): void {
if (promoted.has(clip.el)) return;
promoted.add(clip.el);
if (clip.el.preload !== "auto") {
clip.el.preload = "auto";
}
if (clip.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
clip.el.load();
}
}
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 sync(currentTimeSeconds: number): void {
if (!lazy) return;
const window = getClipsInWindow(currentTimeSeconds);
for (const clip of clips) {
if (window.has(clip)) {
promoteClip(clip);
}
}
}
function preloadAroundTime(timeSeconds: number): void {
if (!lazy) return;
const window = getClipsInWindow(timeSeconds);
for (const clip of clips) {
if (window.has(clip)) {
promoteClip(clip);
}
}
}
function isLazy(): boolean {
return lazy;
}
return { refresh, sync, preloadAroundTime, isLazy };
}