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