From 773c4261e2c02878641518e917b58091bc6015e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 11 May 2026 17:48:50 -0700 Subject: [PATCH] feat(core): lazy media preloading for heavy compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/src/runtime/mediaPreloader.test.ts | 144 ++++++++++++++++++ packages/core/src/runtime/mediaPreloader.ts | 90 +++++++++++ 2 files changed, 234 insertions(+) create mode 100644 packages/core/src/runtime/mediaPreloader.test.ts create mode 100644 packages/core/src/runtime/mediaPreloader.ts diff --git a/packages/core/src/runtime/mediaPreloader.test.ts b/packages/core/src/runtime/mediaPreloader.test.ts new file mode 100644 index 000000000..bb699e72d --- /dev/null +++ b/packages/core/src/runtime/mediaPreloader.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/core/src/runtime/mediaPreloader.ts b/packages/core/src/runtime/mediaPreloader.ts new file mode 100644 index 000000000..05fe09a7a --- /dev/null +++ b/packages/core/src/runtime/mediaPreloader.ts @@ -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(); + 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 { + const windowEnd = timeSeconds + LOOKAHEAD_SECONDS; + const inWindow = new Set(); + + 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 }; +}