mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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.
171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
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 (4–5 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 };
|
||
}
|