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:
Miguel Ángel
2026-05-13 01:48:12 +02:00
committed by GitHub
parent 03475d54c6
commit 91bdffffe6
74 changed files with 11760 additions and 9759 deletions
+196
View File
@@ -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;
}
}
+72
View File
@@ -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
+73
View File
@@ -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})`;
}
+359
View File
@@ -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;
}
}
+85
View File
@@ -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,
};
}
+132
View File
@@ -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);
}
}
+132
View File
@@ -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),
);
}
+50
View File
@@ -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"
);
}