mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user