diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 825966b75..fcb4c6d91 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1224,7 +1224,11 @@ export function initSandboxRuntimeModular(): void { }; const isRenderMode = Boolean((window as Record).__HF_EXPORT_RENDER_SEEK_CONFIG); - const mediaPreloader = createMediaPreloadManager(); + const mediaPreloader = createMediaPreloadManager({ + onActivation: (clipCount) => { + postRuntimeDiagnosticOnce("lazy_preload_activated", { clipCount }, "lazy_preload_activated"); + }, + }); const bindMediaMetadataListeners = () => { if (state.tornDown) return; diff --git a/packages/core/src/runtime/mediaPreloader.test.ts b/packages/core/src/runtime/mediaPreloader.test.ts index 2494814b5..9353f3e56 100644 --- a/packages/core/src/runtime/mediaPreloader.test.ts +++ b/packages/core/src/runtime/mediaPreloader.test.ts @@ -268,4 +268,58 @@ describe("createMediaPreloadManager", () => { const loadCallsAfter = (elements[0].load as ReturnType).mock.calls.length; expect(loadCallsAfter).toBeGreaterThan(loadCallsBefore); }); + + it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => { + 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("calls onActivation when lazy mode activates", () => { + elements = Array.from({ length: 8 }, (_, i) => + mockMediaElement({ start: String(i * 5), duration: "5" }), + ); + setupDOM(elements); + + const onActivation = vi.fn(); + const manager = createMediaPreloadManager({ onActivation }); + manager.refresh(); + + expect(onActivation).toHaveBeenCalledOnce(); + expect(onActivation).toHaveBeenCalledWith(8); + }); + + it("does not call onActivation below threshold", () => { + elements = [ + mockMediaElement({ start: "0", duration: "5" }), + mockMediaElement({ start: "5", duration: "5" }), + ]; + setupDOM(elements); + + const onActivation = vi.fn(); + const manager = createMediaPreloadManager({ onActivation }); + manager.refresh(); + + expect(onActivation).not.toHaveBeenCalled(); + }); + + it("calls onActivation only once across multiple refreshes", () => { + elements = Array.from({ length: 8 }, (_, i) => + mockMediaElement({ start: String(i * 5), duration: "5" }), + ); + setupDOM(elements); + + const onActivation = vi.fn(); + const manager = createMediaPreloadManager({ onActivation }); + manager.refresh(); + manager.refresh(); + manager.refresh(); + + expect(onActivation).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/core/src/runtime/mediaPreloader.ts b/packages/core/src/runtime/mediaPreloader.ts index ce004321a..fd8308631 100644 --- a/packages/core/src/runtime/mediaPreloader.ts +++ b/packages/core/src/runtime/mediaPreloader.ts @@ -1,8 +1,16 @@ import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media"; +// Compositions with fewer than 6 timed clips rarely exceed browser memory +// limits during eager preload. The threshold avoids preload management +// overhead for typical compositions while catching the heavy-media case +// (e.g., 20 clips / 6GB reported in heygen-com/hyperframes#729). const LAZY_THRESHOLD = 6; const LOOKAHEAD_SECONDS = 10; const LOOKAHEAD_MIN_CLIPS = 2; +// Cap on simultaneously promoted (buffered) clips. When the lookahead window +// contains more clips than this (e.g., many short clips), all window clips +// stay promoted — the cap is defense-in-depth, not a hard ceiling. The primary +// memory bound comes from window-based eviction in syncWindow(). const MAX_PROMOTED = 5; export interface MediaPreloadManager { @@ -16,6 +24,7 @@ export function createMediaPreloadManager(options?: { resolveStartSeconds?: (element: Element) => number; resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null; shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; + onActivation?: (clipCount: number) => void; }): MediaPreloadManager { let clips: RuntimeMediaClip[] = []; const promoted = new Set(); @@ -24,11 +33,16 @@ export function createMediaPreloadManager(options?: { /** Stashed original src so we can restore after eviction. */ const originalSrc = new Map(); let lazy = false; + let activationEmitted = false; function refresh(): void { const cache = refreshRuntimeMediaCache(options); clips = cache.mediaClips; lazy = clips.length >= LAZY_THRESHOLD; + if (lazy && !activationEmitted) { + activationEmitted = true; + options?.onActivation?.(clips.length); + } } function evictClip(clip: RuntimeMediaClip): void {