mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
refactor(core): simplify packages/core — dead code, dedup, type safety (#1413)
- Delete unused mediaPreloader module, 5 dead RuntimeState fields, emitPerformanceMetric, lintScriptUrls, 5 variable type guards - Consolidate compiler utilities: unify CSS URL regex, relative URL predicate, MIME map, @import regex, bulk asset rewrite delegation - Cache extractGsapWindows per script (eliminates 2 redundant recast parses per lint run), share stripJsComments and script extraction - Deduplicate GSAP parser: share serializeValue/safeJsKey, centralize converted-id fallback (6 sites), keyframe codegen (3 sites), waypoint extraction, insert-after-anchor, script hoisting - Replace 88 bare any annotations with typed AstNode/AstPath interfaces - Derive RuntimeBridgeControlAction from HyperframeControlAction, alias RuntimePickerElementInfo, share macOS font profiler - Gate generateHyperframesStyles on includeStyles, collapse 4 GSAP property mutation cases into 2 - Extract magic numbers into named constants, replace 5 double casts with type guards and typed accessors (runtime/globals.ts), reduce function complexity in htmlParser and files route
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initRuntimeAnalytics, emitAnalyticsEvent, emitPerformanceMetric } from "./analytics";
|
||||
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
||||
|
||||
describe("runtime analytics", () => {
|
||||
let postMessage: ReturnType<typeof vi.fn>;
|
||||
@@ -58,94 +58,3 @@ describe("runtime analytics", () => {
|
||||
expect(postMessage).toHaveBeenCalledTimes(events.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime performance metrics", () => {
|
||||
let postMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
postMessage = vi.fn();
|
||||
initRuntimeAnalytics(postMessage);
|
||||
// Clean up DevTools marks between tests to avoid cross-test interference.
|
||||
if (typeof performance !== "undefined" && typeof performance.clearMarks === "function") {
|
||||
performance.clearMarks();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a perf metric via postMessage", () => {
|
||||
emitPerformanceMetric("player_scrub_latency", 12.5);
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
source: "hf-preview",
|
||||
type: "perf",
|
||||
name: "player_scrub_latency",
|
||||
value: 12.5,
|
||||
tags: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes tags through", () => {
|
||||
emitPerformanceMetric("player_decoder_count", 3, {
|
||||
composition_id: "abc123",
|
||||
mode: "isolated",
|
||||
});
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
source: "hf-preview",
|
||||
type: "perf",
|
||||
name: "player_decoder_count",
|
||||
value: 3,
|
||||
tags: { composition_id: "abc123", mode: "isolated" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes missing tags to an empty object", () => {
|
||||
emitPerformanceMetric("player_playback_fps", 60);
|
||||
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ tags: {} }));
|
||||
});
|
||||
|
||||
it("supports zero and negative values", () => {
|
||||
emitPerformanceMetric("player_dropped_frames", 0);
|
||||
emitPerformanceMetric("player_media_sync_drift", -8.3);
|
||||
expect(postMessage).toHaveBeenNthCalledWith(1, expect.objectContaining({ value: 0 }));
|
||||
expect(postMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ value: -8.3 }));
|
||||
});
|
||||
|
||||
it("does not throw when postMessage is not set", () => {
|
||||
initRuntimeAnalytics(null as unknown as (payload: unknown) => void);
|
||||
expect(() => emitPerformanceMetric("player_load_time", 250)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when postMessage throws", () => {
|
||||
postMessage.mockImplementation(() => {
|
||||
throw new Error("channel closed");
|
||||
});
|
||||
expect(() => emitPerformanceMetric("player_scrub_latency", 12)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when performance.mark throws", () => {
|
||||
const original = performance.mark;
|
||||
// Vitest provides a real performance API; replace mark with a thrower for this test.
|
||||
performance.mark = vi.fn(() => {
|
||||
throw new Error("mark failed");
|
||||
}) as typeof performance.mark;
|
||||
try {
|
||||
expect(() => emitPerformanceMetric("player_load_time", 100)).not.toThrow();
|
||||
// Even though performance.mark threw, the bridge should still receive the metric.
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "perf", name: "player_load_time", value: 100 }),
|
||||
);
|
||||
} finally {
|
||||
performance.mark = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a User Timing mark with detail for DevTools visibility", () => {
|
||||
if (typeof performance.getEntriesByName !== "function") {
|
||||
// Older test environments — skip the DevTools assertion but don't fail.
|
||||
return;
|
||||
}
|
||||
emitPerformanceMetric("player_composition_switch", 42, { from: "a", to: "b" });
|
||||
const entries = performance.getEntriesByName("player_composition_switch", "mark");
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
const mark = entries[entries.length - 1] as PerformanceMark;
|
||||
expect(mark.detail).toEqual({ value: 42, tags: { from: "a", to: "b" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import { swallow } from "./diagnostics";
|
||||
/**
|
||||
* Runtime analytics & performance telemetry — vendor-agnostic event emission.
|
||||
* Runtime analytics — vendor-agnostic event emission via postMessage.
|
||||
*
|
||||
* The runtime emits structured events via postMessage. The host application
|
||||
* decides what to do with them: forward to PostHog, Mixpanel, Amplitude,
|
||||
* a custom logger, or nothing at all.
|
||||
*
|
||||
* For session replay: initialize your analytics SDK (e.g. PostHog) only in
|
||||
* the parent app with `recordCrossOriginIframes: true`. No SDK needs to run
|
||||
* inside this iframe.
|
||||
*
|
||||
* ## Host app integration
|
||||
*
|
||||
* ```javascript
|
||||
* window.addEventListener("message", (e) => {
|
||||
* if (e.data?.source !== "hf-preview") return;
|
||||
*
|
||||
* if (e.data.type === "analytics") {
|
||||
* // discrete lifecycle events: composition_loaded, played, seeked, etc.
|
||||
* posthog.capture(e.data.event, e.data.properties);
|
||||
* }
|
||||
*
|
||||
* if (e.data.type === "perf") {
|
||||
* // numeric performance metrics: scrub latency, fps, decoder count, etc.
|
||||
* // Aggregate per-session (p50/p95) and forward on flush.
|
||||
* myMetrics.observe(e.data.name, e.data.value, e.data.tags);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
* The host application decides what to do with events: forward to PostHog,
|
||||
* Mixpanel, Amplitude, a custom logger, or nothing at all.
|
||||
*/
|
||||
|
||||
export type RuntimeAnalyticsEvent =
|
||||
@@ -40,16 +16,7 @@ export type RuntimeAnalyticsEvent =
|
||||
|
||||
export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;
|
||||
|
||||
/**
|
||||
* Tags attached to a performance metric — small, low-cardinality identifiers
|
||||
* (composition id hash, media count bucket, browser version, etc.). Same shape
|
||||
* as analytics properties so hosts can forward both through one pipeline.
|
||||
*/
|
||||
export type RuntimePerformanceTags = Record<string, string | number | boolean | null>;
|
||||
|
||||
// Stored reference to the postRuntimeMessage function, set during init.
|
||||
// Avoids a circular import between analytics ↔ bridge. Shared by both
|
||||
// emitAnalyticsEvent and emitPerformanceMetric — one bridge, two channels.
|
||||
let _postMessage: ((payload: unknown) => void) | null = null;
|
||||
|
||||
/**
|
||||
@@ -81,50 +48,3 @@ export function emitAnalyticsEvent(
|
||||
swallow("runtime.analytics.site1", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a numeric performance metric through the bridge.
|
||||
*
|
||||
* Used for player-perf telemetry — scrub latency, sustained fps, dropped
|
||||
* frames, decoder count, composition load time, media sync drift. The host
|
||||
* aggregates per-session values (p50/p95) and forwards to its observability
|
||||
* pipeline on flush.
|
||||
*
|
||||
* Also writes a `performance.mark()` so the metric shows up under the
|
||||
* DevTools Performance panel's "User Timing" track for local debugging,
|
||||
* with `value` and `tags` available on the entry's `detail` field.
|
||||
*
|
||||
* @param name Metric name, e.g. "player_scrub_latency", "player_playback_fps"
|
||||
* @param value Numeric value (units are metric-specific: ms for latency, fps for rate, etc.)
|
||||
* @param tags Optional low-cardinality tags (composition id, media count bucket, etc.)
|
||||
*/
|
||||
export function emitPerformanceMetric(
|
||||
name: string,
|
||||
value: number,
|
||||
tags?: RuntimePerformanceTags,
|
||||
): void {
|
||||
// Local DevTools breadcrumb. Wrapped because performance.mark() can throw on
|
||||
// strict CSP, when the document is not yet ready, or when `detail` is non-cloneable.
|
||||
try {
|
||||
if (typeof performance !== "undefined" && typeof performance.mark === "function") {
|
||||
performance.mark(name, { detail: { value, tags: tags ?? {} } });
|
||||
}
|
||||
} catch (err) {
|
||||
// performance API unavailable or rejected — keep going
|
||||
swallow("runtime.analytics.site2", err);
|
||||
}
|
||||
|
||||
if (!_postMessage) return;
|
||||
try {
|
||||
_postMessage({
|
||||
source: "hf-preview",
|
||||
type: "perf",
|
||||
name,
|
||||
value,
|
||||
tags: tags ?? {},
|
||||
});
|
||||
} catch (err) {
|
||||
// Never let telemetry failures affect the runtime
|
||||
swallow("runtime.analytics.site3", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,24 +27,11 @@
|
||||
* helper call is a real statement, so no `no-empty` warnings ship in the
|
||||
* inlined IIFE.
|
||||
*/
|
||||
export interface SwallowedEvent {
|
||||
/** Short, descriptive label naming the operation that failed. */
|
||||
label: string;
|
||||
/** The thrown value (often an Error, but JS allows anything). */
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface HFDebugSurface {
|
||||
__hfDebug?: boolean;
|
||||
__HYPERFRAMES_DEBUG?: boolean;
|
||||
__hf?: {
|
||||
onSwallowed?: (event: SwallowedEvent) => void;
|
||||
};
|
||||
}
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
|
||||
export function swallow(label: string, error?: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as unknown as HFDebugSurface;
|
||||
const w = getDebugSurface();
|
||||
|
||||
const handler = w.__hf?.onSwallowed;
|
||||
if (handler) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface HFDebugSurface {
|
||||
__hfDebug?: boolean;
|
||||
__HYPERFRAMES_DEBUG?: boolean;
|
||||
__hf?: {
|
||||
onSwallowed?: (event: { label: string; error: unknown }) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function getDebugSurface(): HFDebugSurface {
|
||||
return globalThis as HFDebugSurface;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } 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,
|
||||
src: `blob:mock-${attrs.start}`,
|
||||
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;
|
||||
},
|
||||
removeAttribute: (name: string) => {
|
||||
if (name === "src") {
|
||||
(el as Record<string, unknown>).src = "";
|
||||
}
|
||||
},
|
||||
closest: () => null,
|
||||
load: vi.fn(),
|
||||
} 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;
|
||||
}
|
||||
|
||||
function createTestFixture(
|
||||
count: number,
|
||||
options?: Parameters<typeof createMediaPreloadManager>[0],
|
||||
) {
|
||||
const elements = Array.from({ length: count }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
const manager = createMediaPreloadManager(options);
|
||||
manager.refresh();
|
||||
return { elements, manager };
|
||||
}
|
||||
|
||||
describe("createMediaPreloadManager", () => {
|
||||
let elements: HTMLMediaElement[];
|
||||
|
||||
beforeEach(() => {
|
||||
elements = [];
|
||||
});
|
||||
|
||||
it("is not lazy when fewer than 3 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 at exactly LAZY_THRESHOLD (3 elements)", () => {
|
||||
const { manager } = createTestFixture(3);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not lazy with 2 elements (below threshold)", () => {
|
||||
const { manager } = createTestFixture(2);
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("activates lazy mode with 8 media elements", () => {
|
||||
const { manager } = createTestFixture(8);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("activates lazy mode for 4-5 clip compositions without spurious eviction", () => {
|
||||
const f = createTestFixture(4);
|
||||
expect(f.manager.isLazy()).toBe(true);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
const promoted = f.elements.filter((el) => el.preload === "auto").length;
|
||||
expect(promoted).toBeGreaterThanOrEqual(2);
|
||||
expect(promoted).toBeLessThanOrEqual(4);
|
||||
f.manager.sync(0);
|
||||
const evicted = f.elements.filter(
|
||||
(el) =>
|
||||
el.preload === "metadata" && (el.load as ReturnType<typeof vi.fn>).mock.calls.length > 1,
|
||||
);
|
||||
expect(evicted.length).toBe(0);
|
||||
});
|
||||
|
||||
it("sync promotes clips in the lookahead window", () => {
|
||||
const f = createTestFixture(8);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
expect(f.elements[1].preload).toBe("auto");
|
||||
expect(f.elements[7].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("preloadAroundTime promotes clips near seek target", () => {
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.preloadAroundTime(30);
|
||||
expect(f.elements[6].preload).toBe("auto");
|
||||
expect(f.elements[7].preload).toBe("auto");
|
||||
expect(f.elements[0].preload).toBe("metadata");
|
||||
});
|
||||
|
||||
it("sync is a no-op when not lazy", () => {
|
||||
const f = createTestFixture(2);
|
||||
f.manager.sync(0);
|
||||
expect(f.manager.isLazy()).toBe(false);
|
||||
});
|
||||
|
||||
it("guarantees at least LOOKAHEAD_MIN_CLIPS are promoted", () => {
|
||||
// Use 20s spacing so only 1 clip falls in the 10s lookahead window
|
||||
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);
|
||||
expect(elements.filter((el) => el.preload === "auto").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("evicts clips when scrubbing away from them", () => {
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
f.manager.sync(40);
|
||||
expect(f.elements[0].preload).toBe("metadata");
|
||||
expect(f.elements[0].src).toBe("");
|
||||
expect(f.elements[8].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("restores src when re-promoting a previously evicted clip", () => {
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
const originalSrc0 = f.elements[0].src;
|
||||
f.manager.sync(0);
|
||||
f.manager.sync(40);
|
||||
expect(f.elements[0].src).toBe("");
|
||||
f.manager.sync(0);
|
||||
expect(f.elements[0].src).toBe(originalSrc0);
|
||||
expect(f.elements[0].preload).toBe("auto");
|
||||
});
|
||||
|
||||
it("does not exceed MAX_PROMOTED (5) clips", () => {
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
|
||||
f.manager.sync(25);
|
||||
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("calls load() when evicting to release buffers", () => {
|
||||
const f = createTestFixture(10);
|
||||
for (const el of f.elements) el.preload = "metadata";
|
||||
f.manager.sync(0);
|
||||
const loadCallsBefore = (f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
f.manager.sync(40);
|
||||
expect((f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
|
||||
loadCallsBefore,
|
||||
);
|
||||
});
|
||||
|
||||
it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => {
|
||||
const { manager } = createTestFixture(6);
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onActivation when lazy mode activates", () => {
|
||||
const onActivation = vi.fn();
|
||||
createTestFixture(8, { onActivation });
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
expect(onActivation).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
it("does not call onActivation below threshold", () => {
|
||||
const onActivation = vi.fn();
|
||||
createTestFixture(2, { onActivation });
|
||||
expect(onActivation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onActivation only once across multiple refreshes", () => {
|
||||
const onActivation = vi.fn();
|
||||
const { manager } = createTestFixture(8, { onActivation });
|
||||
manager.refresh();
|
||||
manager.refresh();
|
||||
expect(onActivation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
|
||||
elements = Array.from({ length: 2 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
// 2 elements is below the default threshold (3) but at our custom one
|
||||
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 2;
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(true);
|
||||
|
||||
// Clean up
|
||||
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
|
||||
});
|
||||
|
||||
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
|
||||
elements = Array.from({ length: 2 }, (_, i) =>
|
||||
mockMediaElement({ start: String(i * 5), duration: "5" }),
|
||||
);
|
||||
setupDOM(elements);
|
||||
|
||||
// Ensure it's not set
|
||||
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
|
||||
|
||||
const manager = createMediaPreloadManager();
|
||||
manager.refresh();
|
||||
|
||||
expect(manager.isLazy()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -10,9 +10,7 @@ describe("createRuntimeState", () => {
|
||||
expect(state.playbackRate).toBe(1);
|
||||
expect(state.bridgeMuted).toBe(false);
|
||||
expect(state.capturedTimeline).toBeNull();
|
||||
expect(state.rafId).toBeNull();
|
||||
expect(state.tornDown).toBe(false);
|
||||
expect(state.parityModeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns independent instances", () => {
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { TransportClock } from "./clock";
|
||||
export type RuntimeState = {
|
||||
capturedTimeline: RuntimeTimelineLike | null;
|
||||
isPlaying: boolean;
|
||||
rafId: number | null;
|
||||
currentTime: number;
|
||||
deterministicAdapters: RuntimeDeterministicAdapter[];
|
||||
parityModeEnabled: boolean;
|
||||
canonicalFps: number;
|
||||
bridgeMuted: boolean;
|
||||
bridgeVolume: number;
|
||||
@@ -62,9 +60,7 @@ export type RuntimeState = {
|
||||
*/
|
||||
bridgeMaxPostIntervalMs: number;
|
||||
controlBridgeHandler: ((event: MessageEvent) => void) | null;
|
||||
clampDurationLoggedRaw: number | null;
|
||||
beforeUnloadHandler: (() => void) | null;
|
||||
domReadyHandler: (() => void) | null;
|
||||
injectedCompStyles: HTMLStyleElement[];
|
||||
injectedCompScripts: HTMLScriptElement[];
|
||||
cachedTimedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
@@ -72,7 +68,6 @@ export type RuntimeState = {
|
||||
cachedVideoClips: RuntimeMediaClip[];
|
||||
cachedMediaTimelineDurationSeconds: number;
|
||||
tornDown: boolean;
|
||||
nativeVisualWatchdogTick: number;
|
||||
/**
|
||||
* Single-clock transport. The sole time authority — GSAP is always
|
||||
* paused and seeked to `clock.now()` on each rAF tick. Eliminates
|
||||
@@ -87,10 +82,8 @@ export function createRuntimeState(): RuntimeState {
|
||||
return {
|
||||
capturedTimeline: null,
|
||||
isPlaying: false,
|
||||
rafId: null,
|
||||
currentTime: 0,
|
||||
deterministicAdapters: [],
|
||||
parityModeEnabled: true,
|
||||
canonicalFps: 30,
|
||||
bridgeMuted: false,
|
||||
bridgeVolume: 1,
|
||||
@@ -104,9 +97,7 @@ export function createRuntimeState(): RuntimeState {
|
||||
bridgeLastPostedMuted: false,
|
||||
bridgeMaxPostIntervalMs: 80,
|
||||
controlBridgeHandler: null,
|
||||
clampDurationLoggedRaw: null,
|
||||
beforeUnloadHandler: null,
|
||||
domReadyHandler: null,
|
||||
injectedCompStyles: [],
|
||||
injectedCompScripts: [],
|
||||
cachedTimedMediaEls: [],
|
||||
@@ -114,7 +105,6 @@ export function createRuntimeState(): RuntimeState {
|
||||
cachedVideoClips: [],
|
||||
cachedMediaTimelineDurationSeconds: 0,
|
||||
tornDown: false,
|
||||
nativeVisualWatchdogTick: 0,
|
||||
transportClock: null,
|
||||
transportRafId: null,
|
||||
};
|
||||
|
||||
@@ -6,17 +6,14 @@ export type RuntimeJson =
|
||||
| RuntimeJson[]
|
||||
| { [key: string]: RuntimeJson };
|
||||
|
||||
import type { HyperframeControlAction } from "../inline-scripts/runtimeContract.js";
|
||||
import type { HyperframePickerElementInfo } from "../inline-scripts/pickerApi.js";
|
||||
|
||||
export type RuntimeBridgeControlAction =
|
||||
| "play"
|
||||
| "pause"
|
||||
| "seek"
|
||||
| HyperframeControlAction
|
||||
| "tick"
|
||||
| "set-muted"
|
||||
| "set-volume"
|
||||
| "set-media-output-muted"
|
||||
| "set-playback-rate"
|
||||
| "enable-pick-mode"
|
||||
| "disable-pick-mode"
|
||||
| "flash-elements";
|
||||
|
||||
export type RuntimeBridgeControlMessage = {
|
||||
@@ -85,23 +82,7 @@ export type RuntimeDiagnosticMessage = {
|
||||
details: Record<string, RuntimeJson>;
|
||||
};
|
||||
|
||||
export type RuntimePickerBoundingBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type RuntimePickerElementInfo = {
|
||||
id: string | null;
|
||||
tagName: string;
|
||||
selector: string;
|
||||
label: string;
|
||||
boundingBox: RuntimePickerBoundingBox;
|
||||
textContent: string | null;
|
||||
src: string | null;
|
||||
dataAttributes: Record<string, string>;
|
||||
};
|
||||
export type RuntimePickerElementInfo = HyperframePickerElementInfo;
|
||||
|
||||
export type RuntimePickerHoveredMessage = {
|
||||
source: "hf-preview";
|
||||
|
||||
Reference in New Issue
Block a user