Files
hyperframes/packages/core/src/runtime/mediaPreloader.ts
T
Miguel Ángel 89f9ca196d fix(studio): enable timeline resize for all elements, improve perf and UX
Enable trim-start and trim-end for all authored timeline elements (divs,
sections, compositions) — not just video/audio/img. The deterministic-window
gate was overly restrictive since all non-implicit elements have authored
data-start/data-duration that define their timeline window.

Replace iframe reload after resize/move with direct DOM attribute patching
via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual
blinking, and race conditions from file-watcher echoes. File persistence
runs in a serialized background queue (persistTimelineEdit + enqueueEdit)
so rapid edits don't overwrite each other.

Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata
extraction from file headers. Timeline elements missing sourceDuration are
enriched asynchronously without waiting for DOM loadedmetadata events.

Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips,
add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap.

Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas
and passed as a prop to TimelineClip instead of recomputing per clip.

Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers
import directly from playbackTypes.
2026-05-19 20:40:39 -04:00

171 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
// Start lazy preload management at 3 clips to keep memory pressure low from
// the start. The previous threshold of 6 let medium compositions (45 heavy
// videos) saturate browser memory before the preloader kicked in.
const LAZY_THRESHOLD = 3;
const LOOKAHEAD_SECONDS = 10;
const LOOKBEHIND_SECONDS = 3;
const LOOKAHEAD_MIN_CLIPS = 2;
// Adaptive cap: base of 4 for small sets, clamped to 6 for larger ones.
// The window-based eviction in syncWindow() is the primary memory bound;
// this cap is defense-in-depth for compositions with many short clips
// packed into the lookahead window.
const MAX_PROMOTED_BASE = 4;
const MAX_PROMOTED_CEIL = 6;
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;
onActivation?: (clipCount: number) => void;
}): MediaPreloadManager {
let clips: RuntimeMediaClip[] = [];
const promoted = new Set<HTMLMediaElement>();
/** Insertion-order queue for LRU eviction (oldest first). */
const promotionOrder: HTMLMediaElement[] = [];
/** Stashed original src so we can restore after eviction. */
const originalSrc = new Map<HTMLMediaElement, string>();
let lazy = false;
let activationEmitted = false;
function refresh(): void {
const cache = refreshRuntimeMediaCache(options);
clips = cache.mediaClips;
const configuredThreshold =
typeof (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD === "number"
? ((window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD as number)
: LAZY_THRESHOLD;
lazy = clips.length >= configuredThreshold;
if (lazy && !activationEmitted) {
activationEmitted = true;
options?.onActivation?.(clips.length);
}
}
function evictClip(clip: RuntimeMediaClip): void {
if (!promoted.has(clip.el)) return;
// Stash original src before clearing
if (!originalSrc.has(clip.el)) {
originalSrc.set(clip.el, clip.el.src);
}
// Release buffered data: only way to free memory per MDN
clip.el.removeAttribute("src");
clip.el.load();
clip.el.preload = "metadata";
promoted.delete(clip.el);
const idx = promotionOrder.indexOf(clip.el);
if (idx !== -1) promotionOrder.splice(idx, 1);
}
function promoteClip(clip: RuntimeMediaClip): void {
if (promoted.has(clip.el)) return;
// Restore src if previously evicted
const stashedSrc = originalSrc.get(clip.el);
if (stashedSrc !== undefined && !clip.el.src) {
clip.el.src = stashedSrc;
originalSrc.delete(clip.el);
}
promoted.add(clip.el);
promotionOrder.push(clip.el);
if (clip.el.preload !== "auto") {
clip.el.preload = "auto";
}
if (clip.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
clip.el.load();
}
}
function evictOutsideWindow(inWindow: Set<RuntimeMediaClip>): void {
const windowEls = new Set<HTMLMediaElement>();
for (const clip of inWindow) {
windowEls.add(clip.el);
}
for (const clip of clips) {
if (promoted.has(clip.el) && !windowEls.has(clip.el)) {
evictClip(clip);
}
}
const maxPromoted = Math.min(
MAX_PROMOTED_CEIL,
MAX_PROMOTED_BASE + Math.floor(clips.length / 10),
);
while (promotionOrder.length > maxPromoted) {
const oldest = promotionOrder[0];
if (windowEls.has(oldest)) break;
const clip = clips.find((c) => c.el === oldest);
if (clip) {
evictClip(clip);
} else {
promoted.delete(oldest);
promotionOrder.shift();
}
}
}
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
const windowStart = timeSeconds - LOOKBEHIND_SECONDS;
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;
const inLookbehind = clip.end > windowStart && clip.end <= timeSeconds;
if (active || inLookahead || inLookbehind) {
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 syncWindow(timeSeconds: number): void {
const window = getClipsInWindow(timeSeconds);
evictOutsideWindow(window);
for (const clip of clips) {
if (window.has(clip)) {
promoteClip(clip);
}
}
}
function sync(currentTimeSeconds: number): void {
if (!lazy) return;
syncWindow(currentTimeSeconds);
}
function preloadAroundTime(timeSeconds: number): void {
if (!lazy) return;
syncWindow(timeSeconds);
}
function isLazy(): boolean {
return lazy;
}
return { refresh, sync, preloadAroundTime, isLazy };
}