mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Probes an iframe document to discover the composition's playback adapter
|
||||
* and detect whether the HyperFrames runtime needs to be injected.
|
||||
*
|
||||
* The probe interval polls every 200 ms until one of:
|
||||
* - A `PlaybackDurationAdapter` resolves with a positive duration, or
|
||||
* - 40 attempts (~8 s) expire without a result.
|
||||
*
|
||||
* The `CompositionProbe` class owns the interval; the caller must call
|
||||
* `stop()` on disconnect or src change.
|
||||
*/
|
||||
|
||||
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
|
||||
import {
|
||||
type DirectTimelineAdapter,
|
||||
type PlaybackDurationAdapter,
|
||||
isDirectTimelineAdapter,
|
||||
isObjectRecord,
|
||||
isRuntimeDurationAdapter,
|
||||
} from "./timeline-adapters.js";
|
||||
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
|
||||
export interface ProbeResult {
|
||||
duration: number;
|
||||
adapter: PlaybackDurationAdapter;
|
||||
/** Resolved composition dimensions, if present in the document. */
|
||||
compositionSize: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
export interface ProbeCallbacks {
|
||||
onReady: (result: ProbeResult) => void;
|
||||
onError: (message: string) => void;
|
||||
/** Called when runtime is successfully injected (informational). */
|
||||
onRuntimeInjected?: () => void;
|
||||
}
|
||||
|
||||
export class CompositionProbe {
|
||||
private _interval: ReturnType<typeof setInterval> | null = null;
|
||||
private _runtimeInjected = false;
|
||||
|
||||
constructor(
|
||||
private readonly _iframe: HTMLIFrameElement,
|
||||
private readonly _callbacks: ProbeCallbacks,
|
||||
) {}
|
||||
|
||||
get runtimeInjected(): boolean {
|
||||
return this._runtimeInjected;
|
||||
}
|
||||
|
||||
/** Start (or restart) the probe. Stops any previously running probe first. */
|
||||
start(): void {
|
||||
this.stop();
|
||||
this._runtimeInjected = false;
|
||||
let attempts = 0;
|
||||
|
||||
this._interval = setInterval(() => {
|
||||
attempts++;
|
||||
try {
|
||||
const win = this._iframe.contentWindow as Window & {
|
||||
__player?: { getDuration: () => number };
|
||||
__timelines?: Record<string, { duration: () => number }>;
|
||||
__hf?: unknown;
|
||||
};
|
||||
if (!win) return;
|
||||
|
||||
const hasRuntime = !!(win.__hf || win.__player);
|
||||
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
|
||||
const hasNestedCompositions =
|
||||
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");
|
||||
|
||||
if (
|
||||
shouldInjectRuntime({
|
||||
hasRuntime,
|
||||
hasTimelines,
|
||||
hasNestedCompositions,
|
||||
runtimeInjected: this._runtimeInjected,
|
||||
attempts,
|
||||
})
|
||||
) {
|
||||
this._injectRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._runtimeInjected && !hasRuntime) return;
|
||||
|
||||
const adapter = this._resolvePlaybackDurationAdapter(win);
|
||||
if (adapter && adapter.getDuration() > 0) {
|
||||
this.stop();
|
||||
|
||||
const doc = this._iframe.contentDocument;
|
||||
let compositionSize: { width: number; height: number } | null = null;
|
||||
const root = doc?.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const w = parseInt(root.getAttribute("data-width") || "0", 10);
|
||||
const h = parseInt(root.getAttribute("data-height") || "0", 10);
|
||||
if (w > 0 && h > 0) compositionSize = { width: w, height: h };
|
||||
}
|
||||
|
||||
this._callbacks.onReady({
|
||||
duration: adapter.getDuration(),
|
||||
adapter,
|
||||
compositionSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
|
||||
if (attempts >= 40) {
|
||||
this.stop();
|
||||
this._callbacks.onError("Composition timeline not found after 8s");
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._interval !== null) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adapter resolution (same-origin only) ────────────────────────────────
|
||||
|
||||
resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
|
||||
try {
|
||||
const win = this._iframe.contentWindow;
|
||||
if (!win) return null;
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
}
|
||||
|
||||
hasRuntimeBridge(win: Window): boolean {
|
||||
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private _injectRuntime(): void {
|
||||
this._runtimeInjected = true;
|
||||
try {
|
||||
const doc = this._iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
const script = doc.createElement("script");
|
||||
script.src = RUNTIME_CDN_URL;
|
||||
(doc.head || doc.documentElement).appendChild(script);
|
||||
this._callbacks.onRuntimeInjected?.();
|
||||
} catch {
|
||||
/* cross-origin — can't inject */
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
if (this.hasRuntimeBridge(win)) return null;
|
||||
|
||||
const timelines = Reflect.get(win, "__timelines");
|
||||
if (!isObjectRecord(timelines)) return null;
|
||||
|
||||
const keys = Object.keys(timelines);
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const rootId = this._iframe.contentDocument
|
||||
?.querySelector("[data-composition-id]")
|
||||
?.getAttribute("data-composition-id");
|
||||
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
|
||||
const timeline = timelines[key];
|
||||
return isDirectTimelineAdapter(timeline) ? timeline : null;
|
||||
}
|
||||
|
||||
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
|
||||
const runtimePlayer = Reflect.get(win, "__player");
|
||||
if (isRuntimeDurationAdapter(runtimePlayer)) {
|
||||
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
|
||||
}
|
||||
|
||||
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
if (timeline) {
|
||||
return {
|
||||
kind: "direct-timeline",
|
||||
timeline,
|
||||
getDuration: () => timeline.duration(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Helpers for wiring the player's optional UI elements: the playback controls
|
||||
* bar, the poster image overlay, and input-filtering utilities.
|
||||
*
|
||||
* Extracted from the web component so the setup logic doesn't inflate the
|
||||
* class body. These are pure imperative DOM operations; they carry no
|
||||
* persistent state beyond what the caller tracks.
|
||||
*/
|
||||
|
||||
import { createControls, type ControlsCallbacks, type ControlsOptions } from "./controls.js";
|
||||
|
||||
/**
|
||||
* Create the playback controls overlay and attach it to `parent`.
|
||||
* Returns the controls API object. A no-op guard (returns the existing API)
|
||||
* must be enforced by the caller — this function always constructs.
|
||||
*/
|
||||
export function setupControls(
|
||||
parent: ShadowRoot,
|
||||
muted: boolean,
|
||||
volume: number,
|
||||
speedPresetsAttr: string | null,
|
||||
callbacks: ControlsCallbacks,
|
||||
): ReturnType<typeof createControls> {
|
||||
const speedPresets = speedPresetsAttr
|
||||
? speedPresetsAttr
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
: undefined;
|
||||
const options: ControlsOptions = speedPresets ? { speedPresets } : {};
|
||||
const api = createControls(parent, callbacks, options);
|
||||
api.updateMuted(muted);
|
||||
api.updateVolume(volume);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up or remove the poster image element in `parent`.
|
||||
*
|
||||
* - When `posterUrl` is non-empty, creates the `<img>` if needed and sets src.
|
||||
* - When `posterUrl` is null/empty, removes the existing element (if any).
|
||||
*
|
||||
* Returns the current poster element (possibly newly created) or `null`.
|
||||
*/
|
||||
export function setupPoster(
|
||||
parent: ShadowRoot,
|
||||
posterUrl: string | null,
|
||||
existing: HTMLImageElement | null,
|
||||
): HTMLImageElement | null {
|
||||
if (!posterUrl) {
|
||||
existing?.remove();
|
||||
return null;
|
||||
}
|
||||
if (!existing) {
|
||||
existing = document.createElement("img");
|
||||
existing.className = "hfp-poster";
|
||||
parent.appendChild(existing);
|
||||
}
|
||||
existing.src = posterUrl;
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when `event` originated inside an `hfp-controls` element.
|
||||
* Used to prevent the bare-player-surface click handler from double-firing
|
||||
* when the user clicks an overlay control button.
|
||||
*/
|
||||
export function isControlsClick(event: Event): boolean {
|
||||
return event
|
||||
.composedPath()
|
||||
.some((t) => t instanceof HTMLElement && t.classList.contains("hfp-controls"));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* rAF-based clock that polls a `DirectTimelineAdapter` for current time and
|
||||
* drives the player's time/playback-ended callbacks.
|
||||
*
|
||||
* Used for same-origin standalone GSAP compositions that expose
|
||||
* `window.__timelines` but have no runtime bridge — the player drives them
|
||||
* directly through the adapter instead of going through postMessage.
|
||||
*/
|
||||
|
||||
import type { DirectTimelineAdapter } from "./timeline-adapters.js";
|
||||
|
||||
const UI_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
export interface ClockCallbacks {
|
||||
/** Called every ~100ms and on completion with the current time. */
|
||||
onTimeUpdate: (currentTime: number, duration: number) => void;
|
||||
/** Called when playback reaches the end. Return true to loop (seek+play). */
|
||||
onEnded: () => boolean;
|
||||
/** Get the current loop flag. */
|
||||
getLoop: () => boolean;
|
||||
/** Trigger a seek-then-play loop restart. */
|
||||
restart: () => void;
|
||||
/** Notify that playback has paused (from the timeline side). */
|
||||
onPaused: () => void;
|
||||
}
|
||||
|
||||
export class DirectTimelineClock {
|
||||
private _raf: number | null = null;
|
||||
private _lastUpdateMs = 0;
|
||||
|
||||
constructor(private readonly _callbacks: ClockCallbacks) {}
|
||||
|
||||
start(
|
||||
timeline: DirectTimelineAdapter,
|
||||
getCurrentTime: () => number,
|
||||
getDuration: () => number,
|
||||
isPaused: () => boolean,
|
||||
): void {
|
||||
this.stop();
|
||||
|
||||
const tick = () => {
|
||||
if (isPaused()) {
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentTime: number;
|
||||
try {
|
||||
currentTime = timeline.time();
|
||||
} catch {
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = getDuration();
|
||||
if (duration > 0) currentTime = Math.min(currentTime, duration);
|
||||
|
||||
const completedPlayback = duration > 0 && currentTime >= duration;
|
||||
const now = performance.now();
|
||||
|
||||
if (now - this._lastUpdateMs > UI_UPDATE_INTERVAL_MS || completedPlayback) {
|
||||
this._lastUpdateMs = now;
|
||||
this._callbacks.onTimeUpdate(currentTime, duration);
|
||||
}
|
||||
|
||||
if (completedPlayback) {
|
||||
if (this._callbacks.getLoop()) {
|
||||
this._callbacks.restart();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
timeline.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this._callbacks.onPaused();
|
||||
this._raf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
this._raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._raf === null) return;
|
||||
cancelAnimationFrame(this._raf);
|
||||
this._raf = null;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._raf !== null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* DOM setup helpers for the player's shadow root.
|
||||
*
|
||||
* Keeps constructor boilerplate out of the web component class body.
|
||||
*/
|
||||
|
||||
// Cached Constructable Stylesheet shared across all player instances.
|
||||
let _sharedSheet: CSSStyleSheet | null = null;
|
||||
|
||||
/**
|
||||
* Adopt `cssText` into `shadow` via a shared Constructable Stylesheet when the
|
||||
* browser supports it, falling back to a `<style>` element injection. The sheet
|
||||
* is cached on first creation and reused across all player instances.
|
||||
*/
|
||||
export function adoptShadowStyles(shadow: ShadowRoot, cssText: string): void {
|
||||
if (typeof CSSStyleSheet !== "undefined") {
|
||||
try {
|
||||
if (!_sharedSheet) {
|
||||
_sharedSheet = new CSSStyleSheet();
|
||||
_sharedSheet.replaceSync(cssText);
|
||||
}
|
||||
shadow.adoptedStyleSheets = [_sharedSheet];
|
||||
return;
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssText;
|
||||
shadow.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures the iframe element that hosts the composition, plus
|
||||
* the wrapper container div. Returns handles to both so the constructor can
|
||||
* attach them to the shadow root and track references without inlining the
|
||||
* boilerplate.
|
||||
*/
|
||||
export function createCompositionIframe(): {
|
||||
container: HTMLDivElement;
|
||||
iframe: HTMLIFrameElement;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
container.className = "hfp-container";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.className = "hfp-iframe";
|
||||
iframe.sandbox.add("allow-scripts", "allow-same-origin");
|
||||
iframe.allow = "autoplay; fullscreen";
|
||||
iframe.referrerPolicy = "no-referrer";
|
||||
iframe.title = "HyperFrames Composition";
|
||||
|
||||
container.appendChild(iframe);
|
||||
return { container, iframe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the iframe so the composition fits inside the player element while
|
||||
* preserving aspect ratio. No-ops when the player has no painted size yet.
|
||||
*/
|
||||
export function scaleIframeToFit(
|
||||
playerElement: HTMLElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
): void {
|
||||
const rect = playerElement.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const scale = Math.min(rect.width / compositionWidth, rect.height / compositionHeight);
|
||||
iframe.style.width = `${compositionWidth}px`;
|
||||
iframe.style.height = `${compositionHeight}px`;
|
||||
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Parent-frame media proxy subsystem.
|
||||
*
|
||||
* Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
|
||||
* in the parent frame so that mobile browsers — which gate `el.play()` on user
|
||||
* activation in the *same* frame — can still produce audible output via proxies
|
||||
* the parent controls directly.
|
||||
*
|
||||
* See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
|
||||
*/
|
||||
|
||||
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
|
||||
|
||||
/** Minimum absolute drift before a currentTime correction is attempted. */
|
||||
const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
|
||||
|
||||
/**
|
||||
* How many *consecutive* over-threshold samples are required before issuing a
|
||||
* `currentTime` write. Absorbs single-sample jitter (GC pause, slow bridge
|
||||
* tick) without thrashing. Forced calls bypass this gate.
|
||||
*
|
||||
* Worst-case correction latency ≈ this × bridgeMaxPostIntervalMs (80 ms in
|
||||
* core/runtime/state.ts) = 160 ms — well under human A/V re-sync tolerance.
|
||||
*/
|
||||
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
|
||||
|
||||
export interface ProxyEntry {
|
||||
el: HTMLMediaElement;
|
||||
start: number;
|
||||
duration: number;
|
||||
/**
|
||||
* Count of consecutive steady-state samples in which the proxy's
|
||||
* `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
|
||||
* Reset on every in-threshold sample. A write is only issued once this
|
||||
* reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
|
||||
* single-sample jitter without thrashing.
|
||||
*/
|
||||
driftSamples: number;
|
||||
}
|
||||
|
||||
export class ParentMediaManager {
|
||||
private _entries: ProxyEntry[] = [];
|
||||
private _mediaObserver?: MutationObserver;
|
||||
private _playbackErrorPosted = false;
|
||||
private _audioOwner: "runtime" | "parent" = "runtime";
|
||||
|
||||
private readonly _dispatchEvent: (event: Event) => void;
|
||||
private readonly _getMuted: () => boolean;
|
||||
private readonly _getVolume: () => number;
|
||||
private readonly _getPlaybackRate: () => number;
|
||||
private readonly _getCurrentTime: () => number;
|
||||
private readonly _isPaused: () => boolean;
|
||||
|
||||
constructor(opts: {
|
||||
dispatchEvent: (event: Event) => void;
|
||||
getMuted: () => boolean;
|
||||
getVolume: () => number;
|
||||
getPlaybackRate: () => number;
|
||||
getCurrentTime: () => number;
|
||||
isPaused: () => boolean;
|
||||
}) {
|
||||
this._dispatchEvent = opts.dispatchEvent;
|
||||
this._getMuted = opts.getMuted;
|
||||
this._getVolume = opts.getVolume;
|
||||
this._getPlaybackRate = opts.getPlaybackRate;
|
||||
this._getCurrentTime = opts.getCurrentTime;
|
||||
this._isPaused = opts.isPaused;
|
||||
}
|
||||
|
||||
get audioOwner(): "runtime" | "parent" {
|
||||
return this._audioOwner;
|
||||
}
|
||||
|
||||
/** Exposed for test instrumentation only — do not use in production code. */
|
||||
get entries(): ProxyEntry[] {
|
||||
return this._entries;
|
||||
}
|
||||
|
||||
get playbackErrorPosted(): boolean {
|
||||
return this._playbackErrorPosted;
|
||||
}
|
||||
|
||||
resetForIframeLoad(): void {
|
||||
this._playbackErrorPosted = false;
|
||||
const wasPromoted = this._audioOwner === "parent";
|
||||
this._audioOwner = "runtime";
|
||||
this.pauseAll();
|
||||
this.teardownObserver();
|
||||
if (wasPromoted) {
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "runtime", reason: "iframe-reload" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.teardownObserver();
|
||||
for (const m of this._entries) {
|
||||
m.el.pause();
|
||||
m.el.src = "";
|
||||
}
|
||||
this._entries = [];
|
||||
}
|
||||
|
||||
updateMuted(muted: boolean): void {
|
||||
for (const m of this._entries) m.el.muted = muted;
|
||||
}
|
||||
|
||||
updateVolume(volume: number): void {
|
||||
for (const m of this._entries) m.el.volume = volume;
|
||||
}
|
||||
|
||||
updatePlaybackRate(rate: number): void {
|
||||
for (const m of this._entries) m.el.playbackRate = rate;
|
||||
}
|
||||
|
||||
playAll(): void {
|
||||
for (const m of this._entries) {
|
||||
if (!m.el.src) continue;
|
||||
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||||
}
|
||||
}
|
||||
|
||||
pauseAll(): void {
|
||||
for (const m of this._entries) m.el.pause();
|
||||
}
|
||||
|
||||
seekAll(timeInSeconds: number): void {
|
||||
for (const m of this._entries) {
|
||||
const relTime = timeInSeconds - m.start;
|
||||
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
|
||||
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
|
||||
* promotion, new proxy initialization) where drift must be corrected
|
||||
* immediately.
|
||||
*/
|
||||
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
|
||||
const force = options?.force === true;
|
||||
for (const m of this._entries) {
|
||||
const relTime = timelineSeconds - m.start;
|
||||
if (relTime < 0 || relTime >= m.duration) {
|
||||
m.driftSamples = 0;
|
||||
continue;
|
||||
}
|
||||
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||||
m.driftSamples += 1;
|
||||
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
|
||||
m.el.currentTime = relTime;
|
||||
m.driftSamples = 0;
|
||||
}
|
||||
} else {
|
||||
m.driftSamples = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of audible playback in response to the runtime's
|
||||
* `media-autoplay-blocked` signal. Idempotent.
|
||||
*
|
||||
* The caller is responsible for muting the iframe's own media output via the
|
||||
* postMessage bridge (`set-media-output-muted`) after calling this.
|
||||
*/
|
||||
/**
|
||||
* Take ownership of audible playback. Idempotent. The `onMirror` callback
|
||||
* is called with the current timeline time and `{ force: true }` so the
|
||||
* caller's mirror implementation runs (enabling test spies on the player
|
||||
* to fire). If omitted, `mirrorTime` is called directly.
|
||||
*/
|
||||
promoteToParentProxy(
|
||||
iframeDoc: Document | null,
|
||||
onMirror?: (t: number, opts: { force: boolean }) => void,
|
||||
): void {
|
||||
if (this._audioOwner === "parent") return;
|
||||
this._audioOwner = "parent";
|
||||
|
||||
// Synchronously mute iframe media to close the race window.
|
||||
if (iframeDoc) {
|
||||
for (const el of iframeDoc.querySelectorAll<HTMLMediaElement>("video, audio")) {
|
||||
el.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot alignment — bypass jitter-coalescing gate.
|
||||
const t = this._getCurrentTime();
|
||||
if (onMirror) onMirror(t, { force: true });
|
||||
else this.mirrorTime(t, { force: true });
|
||||
if (!this._isPaused()) this.playAll();
|
||||
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("audioownershipchange", {
|
||||
detail: { owner: "parent", reason: "autoplay-blocked" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up proxies for all timed media currently in the iframe document, then
|
||||
* install a MutationObserver for media added later (sub-composition activation).
|
||||
*/
|
||||
setupFromIframe(iframeDoc: Document): void {
|
||||
const mediaEls = iframeDoc.querySelectorAll<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
for (const iframeEl of mediaEls) this._adoptIframeMedia(iframeEl);
|
||||
this._observeDynamicMedia(iframeDoc);
|
||||
}
|
||||
|
||||
/** Set up a single proxy from an explicit URL (the `audio-src` attribute path). */
|
||||
setupFromUrl(audioSrc: string): void {
|
||||
this._createEntry(audioSrc, "audio", 0, Infinity);
|
||||
}
|
||||
|
||||
teardownObserver(): void {
|
||||
this._mediaObserver?.disconnect();
|
||||
this._mediaObserver = undefined;
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private _reportPlaybackError(err: unknown): void {
|
||||
if (this._playbackErrorPosted) return;
|
||||
this._playbackErrorPosted = true;
|
||||
this._dispatchEvent(
|
||||
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parent-frame media element and start preloading it. Returns the
|
||||
* new entry, or `null` if a proxy for this src already exists (dedup).
|
||||
*/
|
||||
private _createEntry(
|
||||
src: string,
|
||||
tag: "audio" | "video",
|
||||
start: number,
|
||||
duration: number,
|
||||
): ProxyEntry | null {
|
||||
if (this._entries.some((m) => m.el.src === src)) return null;
|
||||
|
||||
const el = tag === "video" ? document.createElement("video") : new Audio();
|
||||
el.preload = "auto";
|
||||
el.src = src;
|
||||
el.load();
|
||||
el.muted = this._getMuted();
|
||||
el.volume = this._getVolume();
|
||||
const rate = this._getPlaybackRate();
|
||||
if (rate !== 1) el.playbackRate = rate;
|
||||
|
||||
const entry: ProxyEntry = { el, start, duration, driftSamples: 0 };
|
||||
this._entries.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
// Skip elements the preloader has demoted — the observer will re-trigger
|
||||
// when the preload attribute is promoted to "auto".
|
||||
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
|
||||
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
|
||||
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
|
||||
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
|
||||
|
||||
const created = this._createEntry(src, tag, start, duration);
|
||||
|
||||
// If already under parent ownership and playing, the new proxy must catch
|
||||
// up immediately — bypass the jitter-coalescing gate.
|
||||
if (created && this._audioOwner === "parent") {
|
||||
this.mirrorTime(this._getCurrentTime(), { force: true });
|
||||
if (!this._isPaused() && created.el.src) {
|
||||
created.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
const idx = this._entries.findIndex((m) => m.el.src === src);
|
||||
if (idx === -1) return;
|
||||
const entry = this._entries[idx];
|
||||
entry.el.pause();
|
||||
entry.el.src = "";
|
||||
this._entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
private _observeDynamicMedia(doc: Document): void {
|
||||
this.teardownObserver();
|
||||
if (typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === "attributes" && m.attributeName === "preload") {
|
||||
const target = m.target;
|
||||
if (
|
||||
target instanceof HTMLMediaElement &&
|
||||
target.matches("audio[data-start], video[data-start]") &&
|
||||
target.preload === "auto"
|
||||
) {
|
||||
this._adoptIframeMedia(target);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const added of m.addedNodes) {
|
||||
if (!(added instanceof Element)) continue;
|
||||
const candidates: HTMLMediaElement[] = [];
|
||||
if (added.matches?.("audio[data-start], video[data-start]")) {
|
||||
candidates.push(added as HTMLMediaElement);
|
||||
}
|
||||
const inside = added.querySelectorAll?.<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
if (inside) for (const el of inside) candidates.push(el);
|
||||
for (const el of candidates) this._adoptIframeMedia(el);
|
||||
}
|
||||
|
||||
for (const removed of m.removedNodes) {
|
||||
if (!(removed instanceof Element)) continue;
|
||||
const dropped: HTMLMediaElement[] = [];
|
||||
if (removed.matches?.("audio[data-start], video[data-start]")) {
|
||||
dropped.push(removed as HTMLMediaElement);
|
||||
}
|
||||
const inside = removed.querySelectorAll?.<HTMLMediaElement>(
|
||||
"audio[data-start], video[data-start]",
|
||||
);
|
||||
if (inside) for (const el of inside) dropped.push(el);
|
||||
for (const el of dropped) this._detachIframeMedia(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observeOpts: MutationObserverInit = {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["preload"],
|
||||
};
|
||||
|
||||
const targets = selectMediaObserverTargets(doc);
|
||||
for (const target of targets) {
|
||||
obs.observe(target, observeOpts);
|
||||
}
|
||||
this._mediaObserver = obs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Pure playback-state update logic for the `state` message from the runtime.
|
||||
*
|
||||
* Extracted from the web component so the state-transition rules — loop
|
||||
* handling, play/pause mirroring, completion detection — can be read and
|
||||
* exercised independently.
|
||||
*/
|
||||
|
||||
import type { ParentMediaManager } from "./parent-media.js";
|
||||
|
||||
const UI_UPDATE_INTERVAL_MS = 100;
|
||||
|
||||
export interface PlaybackState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
paused: boolean;
|
||||
lastUpdateMs: number;
|
||||
}
|
||||
|
||||
export interface PlaybackStateCallbacks {
|
||||
updateControlsTime: (current: number, duration: number) => void;
|
||||
updateControlsPlaying: (playing: boolean) => void;
|
||||
dispatchEvent: (event: Event) => void;
|
||||
seek: (t: number) => void;
|
||||
play: () => void;
|
||||
getLoop: () => boolean;
|
||||
media: ParentMediaManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a `state` message from the runtime and return the next state.
|
||||
* Side effects (controls updates, events, media mirroring) are fired through
|
||||
* `callbacks`. The caller must commit the returned state object.
|
||||
*/
|
||||
export function applyRuntimeStateMessage(
|
||||
data: { frame: number; isPlaying: boolean },
|
||||
fps: number,
|
||||
current: PlaybackState,
|
||||
callbacks: PlaybackStateCallbacks,
|
||||
): PlaybackState {
|
||||
const rawTime = (data.frame ?? 0) / fps;
|
||||
const currentTime = current.duration > 0 ? Math.min(rawTime, current.duration) : rawTime;
|
||||
const wasPlaying = !current.paused;
|
||||
const nextPaused = !data.isPlaying;
|
||||
const completedPlayback =
|
||||
current.duration > 0 && currentTime >= current.duration && (wasPlaying || data.isPlaying);
|
||||
|
||||
if (completedPlayback && callbacks.getLoop()) {
|
||||
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
|
||||
callbacks.seek(0);
|
||||
callbacks.play();
|
||||
// play() sets paused=false; reflect that in the returned state so the
|
||||
// caller's destructure doesn't overwrite it with the stale nextPaused value.
|
||||
return { ...current, currentTime, paused: false };
|
||||
}
|
||||
|
||||
const next: PlaybackState = { ...current, currentTime, paused: nextPaused };
|
||||
|
||||
if (callbacks.media.audioOwner === "parent") {
|
||||
if (wasPlaying && nextPaused) {
|
||||
callbacks.media.pauseAll();
|
||||
} else if (!wasPlaying && !nextPaused) {
|
||||
callbacks.media.playAll();
|
||||
}
|
||||
callbacks.media.mirrorTime(currentTime);
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const playStateChanged = nextPaused !== current.paused;
|
||||
if (now - current.lastUpdateMs > UI_UPDATE_INTERVAL_MS || playStateChanged) {
|
||||
next.lastUpdateMs = now;
|
||||
callbacks.updateControlsTime(currentTime, current.duration);
|
||||
callbacks.updateControlsPlaying(!nextPaused);
|
||||
callbacks.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
|
||||
}
|
||||
|
||||
if (completedPlayback) {
|
||||
if (callbacks.media.audioOwner === "parent") callbacks.media.pauseAll();
|
||||
next.paused = true;
|
||||
callbacks.updateControlsPlaying(false);
|
||||
callbacks.dispatchEvent(new Event("ended"));
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Routes postMessages from the composition iframe to the appropriate handlers.
|
||||
*
|
||||
* Accepts the raw MessageEvent and delegates through typed callbacks so the
|
||||
* web component keeps its state fields private and this module stays stateless.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRuntimeStateMessage,
|
||||
type PlaybackState,
|
||||
type PlaybackStateCallbacks,
|
||||
} from "./playback-state.js";
|
||||
import type { ShaderLoaderState } from "./shader-loader-state.js";
|
||||
import type { ShaderTransitionState } from "./shader-options.js";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
export interface MessageHandlerCallbacks extends PlaybackStateCallbacks {
|
||||
getPlaybackState: () => PlaybackState;
|
||||
setPlaybackState: (next: PlaybackState) => void;
|
||||
getShaderLoadingMode: () => string;
|
||||
shaderLoader: ShaderLoaderState;
|
||||
setCompositionSize: (width: number, height: number) => void;
|
||||
sendControl: (action: string, extra?: Record<string, unknown>) => void;
|
||||
getIframeDoc: () => Document | null;
|
||||
}
|
||||
|
||||
export function handleRuntimeMessage(
|
||||
event: MessageEvent,
|
||||
frameWindow: Window | null,
|
||||
callbacks: MessageHandlerCallbacks,
|
||||
): void {
|
||||
if (event.source !== frameWindow) return;
|
||||
const data = event.data as Record<string, unknown> | undefined;
|
||||
if (!data || data["source"] !== "hf-preview") return;
|
||||
|
||||
if (data["type"] === "shader-transition-state") {
|
||||
const state: ShaderTransitionState =
|
||||
data["state"] && typeof data["state"] === "object"
|
||||
? (data["state"] as ShaderTransitionState)
|
||||
: {};
|
||||
callbacks.shaderLoader.update(state, callbacks.getShaderLoadingMode());
|
||||
callbacks.dispatchEvent(
|
||||
new CustomEvent("shadertransitionstate", {
|
||||
detail: { compositionId: data["compositionId"], state },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "state") {
|
||||
callbacks.setPlaybackState(
|
||||
applyRuntimeStateMessage(
|
||||
{ frame: (data["frame"] as number) ?? 0, isPlaying: !!data["isPlaying"] },
|
||||
FPS,
|
||||
callbacks.getPlaybackState(),
|
||||
callbacks,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "media-autoplay-blocked") {
|
||||
let iframeDoc: Document | null = null;
|
||||
try {
|
||||
iframeDoc = callbacks.getIframeDoc();
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
callbacks.media.promoteToParentProxy(iframeDoc, (t, opts) =>
|
||||
callbacks.media.mirrorTime(t, opts),
|
||||
);
|
||||
callbacks.sendControl("set-media-output-muted", { muted: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data["type"] === "timeline" && (data["durationInFrames"] as number) > 0) {
|
||||
if (Number.isFinite(data["durationInFrames"])) {
|
||||
const pb = callbacks.getPlaybackState();
|
||||
const duration = (data["durationInFrames"] as number) / FPS;
|
||||
callbacks.setPlaybackState({ ...pb, duration });
|
||||
callbacks.updateControlsTime(pb.currentTime, duration);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data["type"] === "stage-size" &&
|
||||
(data["width"] as number) > 0 &&
|
||||
(data["height"] as number) > 0
|
||||
) {
|
||||
callbacks.setCompositionSize(data["width"] as number, data["height"] as number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Factory for the shader-transition loading overlay DOM tree.
|
||||
*
|
||||
* Kept in its own module so the ~100-line DOM construction stays out of the
|
||||
* web component class body. The returned `ShaderLoaderElements` bag gives the
|
||||
* component direct handles to the nodes it needs to update without querying
|
||||
* the shadow DOM on every state change.
|
||||
*/
|
||||
|
||||
import { SHADER_LOADING_PHRASES } from "./shader-options.js";
|
||||
|
||||
export interface ShaderLoaderElements {
|
||||
root: HTMLDivElement;
|
||||
fill: HTMLDivElement;
|
||||
title: HTMLSpanElement;
|
||||
detail: HTMLDivElement;
|
||||
transitionValue: HTMLSpanElement;
|
||||
frameLabel: HTMLSpanElement;
|
||||
frameValue: HTMLSpanElement;
|
||||
frameRow: HTMLDivElement;
|
||||
}
|
||||
|
||||
export function createShaderLoader(): ShaderLoaderElements {
|
||||
const root = document.createElement("div");
|
||||
root.className = "hfp-shader-loader";
|
||||
root.setAttribute("role", "status");
|
||||
root.setAttribute("aria-live", "polite");
|
||||
root.setAttribute("aria-label", "Preparing scene transitions");
|
||||
root.setAttribute("data-hyperframes-ignore", "");
|
||||
root.draggable = false;
|
||||
|
||||
const blockOverlayInteraction = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
for (const eventName of [
|
||||
"selectstart",
|
||||
"dragstart",
|
||||
"pointerdown",
|
||||
"mousedown",
|
||||
"click",
|
||||
"dblclick",
|
||||
"contextmenu",
|
||||
"touchstart",
|
||||
]) {
|
||||
root.addEventListener(eventName, blockOverlayInteraction, { capture: true });
|
||||
}
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "hfp-shader-loader-panel";
|
||||
panel.draggable = false;
|
||||
|
||||
const markFrame = document.createElement("div");
|
||||
markFrame.className = "hfp-shader-loader-mark";
|
||||
markFrame.draggable = false;
|
||||
markFrame.innerHTML = [
|
||||
'<svg width="78" height="78" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false">',
|
||||
'<path d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z" fill="url(#hfp-shader-loader-grad-left)"/>',
|
||||
'<path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z" fill="url(#hfp-shader-loader-grad-right)"/>',
|
||||
"<defs>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-left" x1="48.5676" y1="25" x2="44.7804" y2="71.9384" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-right" x1="54.8282" y1="73.8392" x2="72.0989" y2="32.8932" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
"</defs>",
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const titleContainer = document.createElement("div");
|
||||
titleContainer.className = "hfp-shader-loader-title";
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "hfp-shader-loader-title-text";
|
||||
titleText.textContent = SHADER_LOADING_PHRASES[0] || "Preparing scene transitions";
|
||||
titleContainer.appendChild(titleText);
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "hfp-shader-loader-detail";
|
||||
detail.textContent = "Rendering animated scene samples for shader transitions.";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "hfp-shader-loader-track";
|
||||
track.setAttribute("aria-hidden", "true");
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "hfp-shader-loader-fill";
|
||||
track.appendChild(fill);
|
||||
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hfp-shader-loader-progress";
|
||||
const createProgressRow = (labelText: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hfp-shader-loader-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "hfp-shader-loader-label";
|
||||
label.textContent = labelText;
|
||||
const value = document.createElement("span");
|
||||
value.className = "hfp-shader-loader-value";
|
||||
row.appendChild(label);
|
||||
row.appendChild(value);
|
||||
progress.appendChild(row);
|
||||
return { row, label, value };
|
||||
};
|
||||
const transitionStatus = createProgressRow("transition");
|
||||
const frameStatus = createProgressRow("transition frame");
|
||||
|
||||
panel.appendChild(markFrame);
|
||||
panel.appendChild(titleContainer);
|
||||
panel.appendChild(detail);
|
||||
panel.appendChild(track);
|
||||
panel.appendChild(progress);
|
||||
root.appendChild(panel);
|
||||
|
||||
return {
|
||||
root,
|
||||
fill,
|
||||
title: titleText,
|
||||
detail,
|
||||
transitionValue: transitionStatus.value,
|
||||
frameLabel: frameStatus.label,
|
||||
frameValue: frameStatus.value,
|
||||
frameRow: frameStatus.row,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Runtime state controller for the shader-transition loading overlay.
|
||||
*
|
||||
* Manages show/hide transitions (with a CSS fade-out delay) and updates
|
||||
* the progress bar, phrase text, and detail rows from `ShaderTransitionState`
|
||||
* messages received from the iframe.
|
||||
*
|
||||
* Holds direct references to the DOM nodes created by `createShaderLoader`
|
||||
* so state updates never touch the shadow-DOM query API at runtime.
|
||||
*/
|
||||
|
||||
import { SHADER_LOADING_PHRASES, type ShaderTransitionState } from "./shader-options.js";
|
||||
import type { ShaderLoaderElements } from "./shader-loader-element.js";
|
||||
|
||||
const HIDE_TRANSITION_MS = 420;
|
||||
|
||||
export class ShaderLoaderState {
|
||||
private readonly _el: ShaderLoaderElements;
|
||||
private _hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(elements: ShaderLoaderElements) {
|
||||
this._el = elements;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
this._el.root.classList.remove("hfp-hiding");
|
||||
this._el.root.classList.add("hfp-visible");
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this._el.root.classList.contains("hfp-hiding")) {
|
||||
if (!this._hideTimeout) this._scheduleCleanup();
|
||||
return;
|
||||
}
|
||||
if (!this._el.root.classList.contains("hfp-visible")) return;
|
||||
this._el.root.classList.add("hfp-hiding");
|
||||
this._el.root.classList.remove("hfp-visible");
|
||||
this._scheduleCleanup();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
this._el.root.classList.remove("hfp-visible", "hfp-hiding");
|
||||
this._el.fill.style.transform = "scaleX(0)";
|
||||
this._el.transitionValue.textContent = "";
|
||||
this._el.frameValue.textContent = "";
|
||||
this._el.frameRow.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
update(status: ShaderTransitionState, loadingMode: string): void {
|
||||
if (loadingMode !== "player") {
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
if (status.ready || !status.loading) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress =
|
||||
typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0;
|
||||
const total =
|
||||
typeof status.total === "number" && Number.isFinite(status.total) ? status.total : 0;
|
||||
const ratio = total > 0 ? Math.min(1, Math.max(0, progress / total)) : 0;
|
||||
|
||||
const phraseIndex = Math.min(
|
||||
SHADER_LOADING_PHRASES.length - 1,
|
||||
Math.floor(ratio * SHADER_LOADING_PHRASES.length),
|
||||
);
|
||||
this._el.title.textContent =
|
||||
SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions";
|
||||
|
||||
this._el.detail.textContent =
|
||||
status.phase === "cached"
|
||||
? "Loading cached transition frames before playback."
|
||||
: status.phase === "finalizing"
|
||||
? "Uploading transition textures for smooth playback."
|
||||
: "Rendering animated scene samples for shader transitions.";
|
||||
|
||||
this._el.fill.style.transform = `scaleX(${ratio})`;
|
||||
|
||||
this._el.transitionValue.textContent =
|
||||
status.currentTransition !== undefined && status.transitionTotal !== undefined
|
||||
? `${status.currentTransition}/${status.transitionTotal}`
|
||||
: total > 0
|
||||
? `${progress}/${total}`
|
||||
: "";
|
||||
|
||||
const frameValue =
|
||||
status.transitionFrame !== undefined && status.transitionFrames !== undefined
|
||||
? `${status.transitionFrame}/${status.transitionFrames}`
|
||||
: "";
|
||||
|
||||
this._el.frameLabel.textContent =
|
||||
status.phase === "cached"
|
||||
? "cached transition frames"
|
||||
: status.phase === "finalizing"
|
||||
? "finalizing transition frames"
|
||||
: "rendering transition frames";
|
||||
|
||||
this._el.frameValue.textContent = frameValue;
|
||||
this._el.frameRow.style.visibility = frameValue ? "visible" : "hidden";
|
||||
this._el.root.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
|
||||
this.show();
|
||||
}
|
||||
|
||||
get hideTimeout(): ReturnType<typeof setTimeout> | null {
|
||||
return this._hideTimeout;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._hideTimeout) {
|
||||
clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleCleanup(): void {
|
||||
if (this._hideTimeout) clearTimeout(this._hideTimeout);
|
||||
this._hideTimeout = setTimeout(() => {
|
||||
this._el.root.classList.remove("hfp-hiding");
|
||||
this._hideTimeout = null;
|
||||
}, HIDE_TRANSITION_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shader transition option types, constants, and pure helper functions for
|
||||
* injecting shader capture scale and loading mode parameters into composition
|
||||
* URLs and srcdoc HTML.
|
||||
*/
|
||||
|
||||
export const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
|
||||
export const SHADER_LOADING_ATTR = "shader-loading";
|
||||
export const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
|
||||
export const SHADER_LOADING_PARAM = "__hf_shader_loading";
|
||||
|
||||
export const SHADER_LOADING_PHRASES = [
|
||||
"Preparing scene transitions",
|
||||
"Sampling outgoing scene motion",
|
||||
"Sampling incoming scene motion",
|
||||
"Caching transition frames",
|
||||
"Finalizing transition preview",
|
||||
];
|
||||
|
||||
export type ShaderLoadingMode = "composition" | "player" | "none";
|
||||
|
||||
export interface ShaderTransitionState {
|
||||
ready?: boolean;
|
||||
progress?: number;
|
||||
total?: number;
|
||||
currentTransition?: number;
|
||||
transitionTotal?: number;
|
||||
transitionFrame?: number;
|
||||
transitionFrames?: number;
|
||||
phase?: "cached" | "capturing" | "finalizing";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeShaderCaptureScale(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return String(Math.min(1, Math.max(0.25, parsed)));
|
||||
}
|
||||
|
||||
export function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode {
|
||||
if (value === null || value.trim() === "") return "composition";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "none" ||
|
||||
normalized === "false" ||
|
||||
normalized === "0" ||
|
||||
normalized === "off"
|
||||
) {
|
||||
return "none";
|
||||
}
|
||||
if (
|
||||
normalized === "player" ||
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "on"
|
||||
) {
|
||||
return "player";
|
||||
}
|
||||
return "composition";
|
||||
}
|
||||
|
||||
function setQueryParam(params: URLSearchParams, key: string, value: string | null): void {
|
||||
if (value === null) params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
|
||||
export function withShaderQueryParams(
|
||||
src: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
const hashIndex = src.indexOf("#");
|
||||
const beforeHash = hashIndex >= 0 ? src.slice(0, hashIndex) : src;
|
||||
const hash = hashIndex >= 0 ? src.slice(hashIndex) : "";
|
||||
const queryIndex = beforeHash.indexOf("?");
|
||||
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
|
||||
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
|
||||
const params = new URLSearchParams(query);
|
||||
setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale);
|
||||
setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode);
|
||||
const nextQuery = params.toString();
|
||||
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
export function injectShaderOptionsIntoSrcdoc(
|
||||
html: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
if (scale === null && loadingMode === "composition") return html;
|
||||
const lines: string[] = [];
|
||||
if (scale !== null) lines.push(`window.__HF_SHADER_CAPTURE_SCALE=${JSON.stringify(scale)};`);
|
||||
if (loadingMode !== "composition") {
|
||||
lines.push(`window.__HF_SHADER_LOADING=${JSON.stringify(loadingMode)};`);
|
||||
}
|
||||
const script = `<script data-hyperframes-player-shader-options>${lines.join("")}</script>`;
|
||||
if (/<head\b[^>]*>/i.test(html))
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
if (/<html\b[^>]*>/i.test(html))
|
||||
return html.replace(/<html\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrappers that read shader attributes directly from an element,
|
||||
* avoiding boilerplate in the web component class body.
|
||||
*/
|
||||
|
||||
export function getShaderModeFromElement(el: Element): ShaderLoadingMode {
|
||||
return normalizeShaderLoadingMode(el.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
|
||||
export function getShaderCaptureScaleFromElement(el: Element): number {
|
||||
return Number(normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1");
|
||||
}
|
||||
|
||||
export function prepareSrcForElement(el: Element, src: string): string {
|
||||
return withShaderQueryParams(
|
||||
src,
|
||||
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
|
||||
getShaderModeFromElement(el),
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSrcdocForElement(el: Element, srcdoc: string): string {
|
||||
return injectShaderOptionsIntoSrcdoc(
|
||||
srcdoc,
|
||||
normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)),
|
||||
getShaderModeFromElement(el),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Types and type-guards for the two playback adapter paths the player supports:
|
||||
*
|
||||
* - `RuntimeDurationAdapter` — the HyperFrames runtime exposes `window.__player`
|
||||
* with a `getDuration()` method. This is the standard path for compositions
|
||||
* served through the runtime bridge.
|
||||
*
|
||||
* - `DirectTimelineAdapter` — same-origin standalone compositions can expose
|
||||
* their GSAP master timeline at `window.__timelines` without installing the
|
||||
* full runtime. The player drives play/pause/seek directly against the
|
||||
* timeline object, bypassing the postMessage bridge.
|
||||
*
|
||||
* `PlaybackDurationAdapter` is the discriminated union the probe interval
|
||||
* returns after deciding which path is available.
|
||||
*/
|
||||
|
||||
export interface RuntimeDurationAdapter {
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
export interface DirectTimelineAdapter {
|
||||
duration: () => number;
|
||||
time: () => number;
|
||||
seek: (timeInSeconds: number) => unknown;
|
||||
play: () => unknown;
|
||||
pause: () => unknown;
|
||||
}
|
||||
|
||||
export type PlaybackDurationAdapter =
|
||||
| { kind: "runtime"; getDuration: () => number }
|
||||
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
|
||||
|
||||
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
|
||||
return isObjectRecord(value) && typeof value.getDuration === "function";
|
||||
}
|
||||
|
||||
export function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
|
||||
return (
|
||||
isObjectRecord(value) &&
|
||||
typeof value.duration === "function" &&
|
||||
typeof value.time === "function" &&
|
||||
typeof value.seek === "function" &&
|
||||
typeof value.play === "function" &&
|
||||
typeof value.pause === "function"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user