mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
833 lines
29 KiB
TypeScript
833 lines
29 KiB
TypeScript
import { CompositionProbe, type ProbeResult, readPositiveDimension } from "./composition-probe.js";
|
|
import { isControlsClick, setupControls, setupPoster } from "./controls-setup.js";
|
|
import { adoptShadowStyles, createCompositionIframe, scaleIframeToFit } from "./iframe-dom.js";
|
|
import { DirectTimelineClock } from "./direct-timeline-clock.js";
|
|
import { ParentMediaManager } from "./parent-media.js";
|
|
import { isRealmHtmlMediaElement } from "./media-element-guards.js";
|
|
import { handleRuntimeMessage } from "./runtime-message-handler.js";
|
|
import {
|
|
SHADER_CAPTURE_SCALE_ATTR,
|
|
SHADER_LOADING_ATTR,
|
|
type ShaderLoadingMode,
|
|
getShaderCaptureScaleFromElement,
|
|
getShaderModeFromElement,
|
|
prepareSrcForElement,
|
|
prepareSrcdocForElement,
|
|
} from "./shader-options.js";
|
|
import { createShaderLoader } from "./shader-loader-element.js";
|
|
import { ShaderLoaderState } from "./shader-loader-state.js";
|
|
import { PLAYER_STYLES } from "./styles.js";
|
|
import { type DirectTimelineAdapter } from "./timeline-adapters.js";
|
|
import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
|
|
|
|
// Playback-rate bounds mirror the runtime clamp in
|
|
// packages/core/src/runtime/init.ts (applyPlaybackRate) and media.ts so the
|
|
// player accepts the same range as the in-iframe runtime: an out-of-range rate
|
|
// would otherwise drive the parent-proxied <audio> outside the bounds the
|
|
// timeline itself respects. Clamping here also shields the native
|
|
// HTMLMediaElement.playbackRate setter, which throws for extreme values in
|
|
// production browsers.
|
|
const MIN_PLAYBACK_RATE = 0.1;
|
|
const MAX_PLAYBACK_RATE = 5;
|
|
|
|
export type ColorGradingTarget =
|
|
| string
|
|
| {
|
|
id?: string | null;
|
|
hfId?: string | null;
|
|
selector?: string | null;
|
|
selectorIndex?: number | null;
|
|
};
|
|
|
|
export type ColorGradingCompareState = {
|
|
enabled: boolean;
|
|
position?: number;
|
|
softness?: number;
|
|
lineWidth?: number;
|
|
};
|
|
|
|
function clampPlaybackRate(rate: number): number {
|
|
if (!Number.isFinite(rate) || rate <= 0) return 1;
|
|
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
|
|
}
|
|
|
|
class HyperframesPlayer extends HTMLElement {
|
|
static get observedAttributes() {
|
|
return [
|
|
"src",
|
|
"srcdoc",
|
|
"width",
|
|
"height",
|
|
"controls",
|
|
"muted",
|
|
"audio-locked",
|
|
"volume",
|
|
"poster",
|
|
"playback-rate",
|
|
"audio-src",
|
|
SHADER_CAPTURE_SCALE_ATTR,
|
|
SHADER_LOADING_ATTR,
|
|
];
|
|
}
|
|
|
|
private shadow: ShadowRoot;
|
|
private container: HTMLDivElement;
|
|
private iframe: HTMLIFrameElement;
|
|
private posterEl: HTMLImageElement | null = null;
|
|
private controlsApi: ReturnType<typeof setupControls> | null = null;
|
|
private resizeObserver: ResizeObserver;
|
|
private shaderLoader: ShaderLoaderState;
|
|
private probe: CompositionProbe;
|
|
|
|
private _ready = false;
|
|
private _currentTime = 0;
|
|
private _duration = 0;
|
|
private _paused = true;
|
|
/** True while the user is dragging the scrubber — makes seek() play audio at the
|
|
* playhead (audible scrub) instead of positioning it silently. */
|
|
private _scrubbing = false;
|
|
private _lastUpdateMs = 0;
|
|
private _volume = 1;
|
|
private _compositionWidth = 1920;
|
|
private _compositionHeight = 1080;
|
|
private _rescaleWarned = false;
|
|
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
|
|
private _directTimelineClock: DirectTimelineClock;
|
|
private _parentTickRaf: number | null = null;
|
|
private _media: ParentMediaManager;
|
|
private _scenes: { id: string; start: number; duration: number }[] = [];
|
|
private _runtimeFps = 30;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
|
|
adoptShadowStyles(this.shadow, PLAYER_STYLES);
|
|
({ container: this.container, iframe: this.iframe } = createCompositionIframe());
|
|
this.shadow.appendChild(this.container);
|
|
|
|
const loaderElements = createShaderLoader();
|
|
this.shadow.appendChild(loaderElements.root);
|
|
this.shaderLoader = new ShaderLoaderState(loaderElements);
|
|
|
|
this._media = new ParentMediaManager({
|
|
dispatchEvent: (e) => this.dispatchEvent(e),
|
|
getMuted: () => this.muted,
|
|
getVolume: () => this._volume,
|
|
getPlaybackRate: () => this.playbackRate,
|
|
getCurrentTime: () => this._currentTime,
|
|
isPaused: () => this._paused,
|
|
});
|
|
|
|
this._directTimelineClock = new DirectTimelineClock({
|
|
onTimeUpdate: (currentTime, duration) => {
|
|
this._currentTime = currentTime;
|
|
this.controlsApi?.updateTime(currentTime, duration);
|
|
this.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
|
|
},
|
|
getLoop: () => this.loop,
|
|
restart: () => {
|
|
this.seek(0);
|
|
this.play();
|
|
},
|
|
onPaused: () => {
|
|
if (this._media.audioOwner === "parent") this._media.pauseAll();
|
|
this._paused = true;
|
|
this.controlsApi?.updatePlaying(false);
|
|
this.dispatchEvent(new Event("ended"));
|
|
},
|
|
onEnded: () => this.loop,
|
|
});
|
|
|
|
this.probe = new CompositionProbe(this.iframe, {
|
|
onReady: (result) => this._onProbeReady(result),
|
|
onError: (message) => this.dispatchEvent(new CustomEvent("error", { detail: { message } })),
|
|
});
|
|
|
|
this.addEventListener("click", (event) => {
|
|
if (isControlsClick(event)) return;
|
|
if (this._paused) this.play();
|
|
else this.pause();
|
|
});
|
|
|
|
this.resizeObserver = new ResizeObserver(() => this._rescale());
|
|
this._onMessage = this._onMessage.bind(this);
|
|
this._onIframeLoad = this._onIframeLoad.bind(this);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.resizeObserver.observe(this);
|
|
window.addEventListener("message", this._onMessage);
|
|
this.iframe.addEventListener("load", this._onIframeLoad);
|
|
if (this.hasAttribute("controls")) this._setupControls();
|
|
if (this.hasAttribute("poster"))
|
|
this.posterEl = setupPoster(this.shadow, this.getAttribute("poster"), this.posterEl);
|
|
if (this.hasAttribute("audio-src")) this._media.setupFromUrl(this.getAttribute("audio-src")!);
|
|
if (this.hasAttribute("srcdoc"))
|
|
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc")!);
|
|
if (this.hasAttribute("src"))
|
|
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src")!);
|
|
|
|
// Host-environment audio lock: when the embedding host (e.g. Claude
|
|
// desktop) drops the `audio-locked` attribute, attributeChangedCallback
|
|
// never fires for it, so apply the lock here based on UA detection.
|
|
if (!this.hasAttribute("audio-locked") && this._isLockedHostEnvironment()) {
|
|
this._applyAudioLock(true);
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._sendControl("pause");
|
|
this._stopIframeMedia();
|
|
this.resizeObserver.disconnect();
|
|
window.removeEventListener("message", this._onMessage);
|
|
this.iframe.removeEventListener("load", this._onIframeLoad);
|
|
this.probe.stop();
|
|
this._directTimelineClock.stop();
|
|
this._stopParentTickClock();
|
|
this._directTimelineAdapter = null;
|
|
this.shaderLoader.destroy();
|
|
this._media.destroy();
|
|
this.controlsApi?.destroy();
|
|
this.controlsApi = null;
|
|
this._paused = true;
|
|
this._ready = false;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
|
switch (name) {
|
|
case "src":
|
|
if (val) {
|
|
this._ready = false;
|
|
this.iframe.src = prepareSrcForElement(this, val);
|
|
}
|
|
break;
|
|
case "srcdoc":
|
|
this._ready = false;
|
|
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
|
|
else this.iframe.removeAttribute("srcdoc");
|
|
break;
|
|
// Reject NaN/zero/negative dimensions the same way the composition
|
|
// probe does (a typo like width="abc" or width="0" would otherwise
|
|
// reach scaleIframeToFit as scale(NaN) or a division by zero and
|
|
// blank the player); fall back to the defaults instead.
|
|
case "width":
|
|
this._compositionWidth = readPositiveDimension(val) ?? 1920;
|
|
this._rescale();
|
|
break;
|
|
case "height":
|
|
this._compositionHeight = readPositiveDimension(val) ?? 1080;
|
|
this._rescale();
|
|
break;
|
|
case "controls":
|
|
if (val !== null) this._setupControls();
|
|
else {
|
|
this.controlsApi?.destroy();
|
|
this.controlsApi = null;
|
|
}
|
|
break;
|
|
case "poster":
|
|
this.posterEl = setupPoster(this.shadow, val, this.posterEl);
|
|
break;
|
|
case "playback-rate": {
|
|
const rate = clampPlaybackRate(parseFloat(val || "1"));
|
|
this._media.updatePlaybackRate(rate);
|
|
this._sendControl("set-playback-rate", { playbackRate: rate });
|
|
this._directTimelineAdapter?.timeScale?.(rate);
|
|
this.controlsApi?.updateSpeed(rate);
|
|
this.dispatchEvent(new Event("ratechange"));
|
|
break;
|
|
}
|
|
case "muted":
|
|
this._handleMutedChange(val);
|
|
break;
|
|
case "audio-locked":
|
|
this._applyAudioLock(val !== null);
|
|
break;
|
|
case "volume": {
|
|
const v = Math.max(0, Math.min(1, parseFloat(val || "1")));
|
|
this._volume = v;
|
|
this._media.updateVolume(v);
|
|
this._sendControl("set-volume", { volume: v });
|
|
this.controlsApi?.updateVolume(v);
|
|
this.dispatchEvent(new Event("volumechange"));
|
|
break;
|
|
}
|
|
case "audio-src":
|
|
if (val) this._media.setupFromUrl(val);
|
|
else this._media.teardownUrlAudio();
|
|
break;
|
|
case SHADER_CAPTURE_SCALE_ATTR:
|
|
case SHADER_LOADING_ATTR:
|
|
this._reloadShaderOptions();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The inner `<iframe>` rendering the composition. Use this when integrating
|
|
* with tools that need `contentWindow` — `.contentWindow` on the
|
|
* `<hyperframes-player>` element itself returns `null` (Shadow DOM).
|
|
*/
|
|
get iframeElement(): HTMLIFrameElement {
|
|
return this.iframe;
|
|
}
|
|
|
|
/** Scene list from the last-received runtime timeline message. Empty until
|
|
* the composition runtime fires its first "timeline" postMessage. */
|
|
get scenes(): { id: string; start: number; duration: number }[] {
|
|
return this._scenes;
|
|
}
|
|
|
|
play() {
|
|
this.posterEl?.remove();
|
|
this.posterEl = null;
|
|
if (this._duration > 0 && this._currentTime >= this._duration) this.seek(0);
|
|
// Must be set before _startParentTickClock so the RAF loop's `_paused`
|
|
// check doesn't immediately self-terminate on the first callback.
|
|
this._paused = false;
|
|
const directTimelineStarted = this._tryDirectTimelinePlay();
|
|
if (!directTimelineStarted) {
|
|
this._sendControl("play");
|
|
// Only start the parent tick clock once the composition is ready and
|
|
// confirmed on the runtime bridge path (not the direct-timeline path).
|
|
// Guards against firing ticks into an uninitialized iframe when play()
|
|
// is called before the probe has resolved.
|
|
if (this._ready && !this._directTimelineAdapter) {
|
|
this._startParentTickClock();
|
|
}
|
|
}
|
|
if (this._media.audioOwner === "parent") this._media.playAll();
|
|
this.controlsApi?.updatePlaying(true);
|
|
this.dispatchEvent(new Event("play"));
|
|
if (directTimelineStarted && this._directTimelineAdapter) {
|
|
this._directTimelineClock.start(
|
|
this._directTimelineAdapter,
|
|
() => this._currentTime,
|
|
() => this._duration,
|
|
() => this._paused,
|
|
);
|
|
}
|
|
}
|
|
|
|
pause() {
|
|
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
|
|
this._directTimelineClock.stop();
|
|
this._stopParentTickClock();
|
|
if (this._media.audioOwner === "parent") this._media.pauseAll();
|
|
this._paused = true;
|
|
this.controlsApi?.updatePlaying(false);
|
|
this.dispatchEvent(new Event("pause"));
|
|
}
|
|
|
|
stopMedia() {
|
|
this._sendControl("stop-media");
|
|
this._stopIframeMedia();
|
|
this._media.stopAdoptedMedia();
|
|
}
|
|
|
|
seek(timeInSeconds: number) {
|
|
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
|
|
this._sendControl("seek", {
|
|
timeSeconds: timeInSeconds,
|
|
// Legacy runtimes read only `frame`. Protocol-v1 runtimes prefer
|
|
// `timeSeconds`, so carrying both keeps cross-origin embeds seekable
|
|
// while preserving seconds-first precision between current peers.
|
|
frame: Math.round(timeInSeconds * this._runtimeFps),
|
|
});
|
|
}
|
|
this._directTimelineClock.stop();
|
|
this._stopParentTickClock();
|
|
this._currentTime = timeInSeconds;
|
|
if (this._media.audioOwner === "parent") {
|
|
if (this._scrubbing) {
|
|
// Audible scrub: play the proxy audio at the playhead so the viewer hears
|
|
// the track as they drag. Each move re-seeks, restarting playback from the
|
|
// new position. onScrubEnd settles back to silence via a normal seek.
|
|
this._media.scrubAll(timeInSeconds);
|
|
} else {
|
|
// Pause BEFORE seek: leaving the proxy playing turns the next
|
|
// `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek
|
|
// stutter loop, where ~80ms of audio plays past the (now frozen) timeline,
|
|
// then mirrorTime yanks `currentTime` back to match it. Symmetric with
|
|
// `pause()` below.
|
|
this._media.pauseAll();
|
|
this._media.seekAll(timeInSeconds);
|
|
}
|
|
}
|
|
this._paused = true;
|
|
this.controlsApi?.updatePlaying(false);
|
|
this.controlsApi?.updateTime(this._currentTime, this._duration);
|
|
}
|
|
|
|
setColorGrading(target: ColorGradingTarget, grading: unknown) {
|
|
this._sendControl("set-color-grading", { target, grading });
|
|
}
|
|
|
|
clearColorGrading(target: ColorGradingTarget) {
|
|
this._sendControl("set-color-grading", { target, grading: null });
|
|
}
|
|
|
|
setColorGradingCompare(target: ColorGradingTarget, compare: ColorGradingCompareState) {
|
|
this._sendControl("set-color-grading-compare", { target, compare });
|
|
}
|
|
|
|
clearColorGradingCompare(target: ColorGradingTarget) {
|
|
this._sendControl("set-color-grading-compare", {
|
|
target,
|
|
compare: { enabled: false },
|
|
});
|
|
}
|
|
|
|
get currentTime() {
|
|
return this._currentTime;
|
|
}
|
|
set currentTime(t: number) {
|
|
this.seek(t);
|
|
}
|
|
|
|
get duration() {
|
|
return this._duration;
|
|
}
|
|
get paused() {
|
|
return this._paused;
|
|
}
|
|
get ready() {
|
|
return this._ready;
|
|
}
|
|
|
|
get playbackRate() {
|
|
return clampPlaybackRate(parseFloat(this.getAttribute("playback-rate") || "1"));
|
|
}
|
|
set playbackRate(r: number) {
|
|
this.setAttribute("playback-rate", String(clampPlaybackRate(r)));
|
|
}
|
|
|
|
get shaderCaptureScale() {
|
|
return getShaderCaptureScaleFromElement(this);
|
|
}
|
|
set shaderCaptureScale(scale: number) {
|
|
this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale));
|
|
}
|
|
|
|
get shaderLoading() {
|
|
return getShaderModeFromElement(this);
|
|
}
|
|
set shaderLoading(mode: ShaderLoadingMode) {
|
|
if (mode === "composition") this.removeAttribute(SHADER_LOADING_ATTR);
|
|
else this.setAttribute(SHADER_LOADING_ATTR, mode);
|
|
}
|
|
|
|
get muted() {
|
|
return this.hasAttribute("muted");
|
|
}
|
|
set muted(m: boolean) {
|
|
if (m) this.setAttribute("muted", "");
|
|
else this.removeAttribute("muted");
|
|
}
|
|
|
|
get audioLocked() {
|
|
return this.hasAttribute("audio-locked");
|
|
}
|
|
set audioLocked(locked: boolean) {
|
|
if (locked) this.setAttribute("audio-locked", "");
|
|
else this.removeAttribute("audio-locked");
|
|
}
|
|
|
|
/**
|
|
* Host renderers that strip unknown custom-element attributes before they
|
|
* reach the DOM (observed on the Claude desktop Electron client) can defeat
|
|
* `audio-locked` even when the host *intends* to lock audio. When we detect
|
|
* such an environment, self-impose the same restriction the attribute would
|
|
* apply. Web (browser) hosts preserve the attribute and don't need this.
|
|
*/
|
|
private _isLockedHostEnvironment(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
const ua = navigator.userAgent || "";
|
|
// Claude desktop ships as an Electron app with a "Claude/<version>" UA token.
|
|
return /\bClaude\/\d/.test(ua) && /\bElectron\b/.test(ua);
|
|
}
|
|
|
|
/** True when audio playback must be locked: attribute OR host fallback. */
|
|
private _isAudioLocked(): boolean {
|
|
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
|
|
}
|
|
|
|
private _isSlideshowPlayer(): boolean {
|
|
return this.closest("hyperframes-slideshow") !== null;
|
|
}
|
|
|
|
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
|
|
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
|
|
private _handleMutedChange(val: string | null): void {
|
|
// While audio is locked, ignore any attempt to clear `muted` (host control,
|
|
// stray script, raw `removeAttribute`) and re-assert it. The re-set fires
|
|
// this callback again with val="" (not null) so it mutes normally — no loop.
|
|
if (val === null && this._isAudioLocked()) {
|
|
this.setAttribute("muted", "");
|
|
return;
|
|
}
|
|
this._media.updateMuted(val !== null);
|
|
this._setIframeMediaMuted(val !== null);
|
|
this._sendControl("set-muted", { muted: val !== null });
|
|
this.controlsApi?.updateMuted(val !== null);
|
|
this.dispatchEvent(new Event("volumechange"));
|
|
}
|
|
|
|
/**
|
|
* Host-mandated silent playback (e.g. embedded in a chat host): force mute
|
|
* and hide the volume controls so the viewer cannot turn sound on. Unlocking
|
|
* only unhides the controls — it does not auto-unmute; callers manage `muted`
|
|
* explicitly after unlocking.
|
|
*/
|
|
private _applyAudioLock(locked: boolean): void {
|
|
if (locked) this.muted = true;
|
|
this.controlsApi?.setVolumeControlsHidden(locked);
|
|
}
|
|
|
|
get volume() {
|
|
return this._volume;
|
|
}
|
|
set volume(v: number) {
|
|
this.setAttribute("volume", String(Math.max(0, Math.min(1, v))));
|
|
}
|
|
|
|
get loop() {
|
|
return this.hasAttribute("loop");
|
|
}
|
|
set loop(l: boolean) {
|
|
if (l) this.setAttribute("loop", "");
|
|
else this.removeAttribute("loop");
|
|
}
|
|
|
|
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
|
|
try {
|
|
this.iframe.contentWindow?.postMessage(
|
|
{
|
|
...extra,
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action,
|
|
...runtimeProtocolMetadata(this._runtimeFps),
|
|
},
|
|
"*",
|
|
);
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the iframe's contentDocument if same-origin and reachable,
|
|
* otherwise null. Accessing contentDocument can throw on cross-origin
|
|
* iframes — this swallows that as a clean null sentinel.
|
|
*/
|
|
private _getSameOriginIframeDocument(): Document | null {
|
|
try {
|
|
return this.iframe.contentDocument;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private _setIframeMediaMuted(muted: boolean): void {
|
|
const iframeDoc = this._getSameOriginIframeDocument();
|
|
if (!iframeDoc) return;
|
|
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
|
if (isRealmHtmlMediaElement(el)) el.muted = muted || el.defaultMuted;
|
|
}
|
|
}
|
|
|
|
private _stopIframeMedia(): void {
|
|
const iframeDoc = this._getSameOriginIframeDocument();
|
|
if (!iframeDoc) return;
|
|
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
|
if (isRealmHtmlMediaElement(el)) el.pause();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replay current bridge state to the iframe runtime. Triggered when the
|
|
* runtime announces `{type: "ready"}` — repairs the race where the parent
|
|
* posts control messages before the iframe's bridge listener is installed
|
|
* (warm-cache reloads, the Claude desktop Electron client, anywhere the
|
|
* iframe finishes loading after we've already called `set-muted` etc).
|
|
* Re-sending current state is idempotent — even at default values it just
|
|
* confirms what the runtime would have done anyway.
|
|
*/
|
|
private _replayBridgeState(): void {
|
|
this._sendControl("set-muted", { muted: this.muted });
|
|
this._sendControl("set-volume", { volume: this._volume });
|
|
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
|
|
this._sendControl("set-native-media-sync-disabled", {
|
|
disabled: this._isSlideshowPlayer(),
|
|
});
|
|
this._sendControl("set-web-audio-media-disabled", {
|
|
disabled: this._isSlideshowPlayer(),
|
|
});
|
|
}
|
|
|
|
private _reloadShaderOptions(): void {
|
|
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
|
|
if (this.hasAttribute("srcdoc")) {
|
|
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
|
|
return;
|
|
}
|
|
if (this.hasAttribute("src")) {
|
|
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src") || "");
|
|
}
|
|
}
|
|
|
|
private _trySyncSeek(timeInSeconds: number): boolean {
|
|
try {
|
|
const win = this.iframe.contentWindow as
|
|
| (Window & { __player?: { seek?: (t: number) => void } })
|
|
| null;
|
|
const player = win?.__player;
|
|
if (typeof player?.seek !== "function") return false;
|
|
player.seek.call(player, timeInSeconds);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private _withDirectTimeline(fn: (tl: DirectTimelineAdapter) => void): boolean {
|
|
const tl = this._directTimelineAdapter || this.probe.resolveDirectTimelineAdapter();
|
|
if (!tl) return false;
|
|
try {
|
|
fn(tl);
|
|
this._directTimelineAdapter = tl;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// GSAP seek() preserves play state; player seek() contract lands paused.
|
|
private _tryDirectTimelineSeek(t: number): boolean {
|
|
return this._withDirectTimeline((tl) => {
|
|
// suppressEvents=false: fire the timeline's onUpdate so compositions that
|
|
// drive scene visibility imperatively (via the root timeline's onUpdate,
|
|
// e.g. slideshow decks) repaint on a paused seek — not only while playing.
|
|
tl.seek(t, false);
|
|
tl.pause();
|
|
});
|
|
}
|
|
private _tryDirectTimelinePlay(): boolean {
|
|
return this._withDirectTimeline((tl) => void tl.play());
|
|
}
|
|
private _tryDirectTimelinePause(): boolean {
|
|
return this._withDirectTimeline((tl) => void tl.pause());
|
|
}
|
|
|
|
/**
|
|
* Widget-frame RAF loop that sends "tick" postMessages to the composition
|
|
* iframe on every frame. Used for the runtime bridge path so that animation
|
|
* advances even when the composition iframe's own rAF is throttled by
|
|
* Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop).
|
|
* The runtime's own rAF loop still runs — ticking GSAP twice per frame is
|
|
* harmless because seekTimelineAndAdapters is idempotent.
|
|
*/
|
|
private _startParentTickClock(): void {
|
|
this._stopParentTickClock();
|
|
const tick = () => {
|
|
if (this._paused) {
|
|
this._parentTickRaf = null;
|
|
return;
|
|
}
|
|
this._sendControl("tick");
|
|
this._parentTickRaf = requestAnimationFrame(tick);
|
|
};
|
|
this._parentTickRaf = requestAnimationFrame(tick);
|
|
}
|
|
|
|
private _stopParentTickClock(): void {
|
|
if (this._parentTickRaf === null) return;
|
|
cancelAnimationFrame(this._parentTickRaf);
|
|
this._parentTickRaf = null;
|
|
}
|
|
|
|
private _onMessage(e: MessageEvent) {
|
|
handleRuntimeMessage(e, this.iframe.contentWindow, {
|
|
getPlaybackState: () => ({
|
|
currentTime: this._currentTime,
|
|
duration: this._duration,
|
|
paused: this._paused,
|
|
lastUpdateMs: this._lastUpdateMs,
|
|
}),
|
|
setPlaybackState: ({ currentTime, duration, paused, lastUpdateMs }) => {
|
|
this._currentTime = currentTime;
|
|
this._duration = duration;
|
|
this._paused = paused;
|
|
this._lastUpdateMs = lastUpdateMs;
|
|
},
|
|
getShaderLoadingMode: () => getShaderModeFromElement(this),
|
|
shaderLoader: this.shaderLoader,
|
|
setCompositionSize: (w, h) => {
|
|
this._compositionWidth = w;
|
|
this._compositionHeight = h;
|
|
this._rescale();
|
|
},
|
|
sendControl: (action, extra) => this._sendControl(action, extra),
|
|
getIframeDoc: () => this.iframe.contentDocument,
|
|
onRuntimeReady: () => this._replayBridgeState(),
|
|
onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration),
|
|
setRuntimeFps: (fps) => {
|
|
this._runtimeFps = fps;
|
|
},
|
|
shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(),
|
|
setScenes: (scenes) => {
|
|
this._scenes = scenes;
|
|
this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } }));
|
|
},
|
|
updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d),
|
|
updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p),
|
|
dispatchEvent: (ev) => this.dispatchEvent(ev),
|
|
seek: (t) => this.seek(t),
|
|
play: () => this.play(),
|
|
getLoop: () => this.loop,
|
|
media: this._media,
|
|
});
|
|
}
|
|
|
|
private _onRuntimeTimelineReady(duration: number) {
|
|
if (this._ready) return;
|
|
this.probe.stop();
|
|
this._duration = duration;
|
|
this._directTimelineAdapter = null;
|
|
this._ready = true;
|
|
this.controlsApi?.updateTime(this._currentTime, duration);
|
|
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
|
|
// stage-size may not have arrived yet (race in the runtime's postTimeline
|
|
// resolving the root's data-width/data-height on first paint) — rescale
|
|
// here too so cross-origin compositions never stay unscaled/untransformed.
|
|
this._rescale();
|
|
|
|
const doc = this._getSameOriginIframeDocument();
|
|
if (doc) this._media.setupFromIframe(doc);
|
|
|
|
this._replayBridgeState();
|
|
this._setIframeMediaMuted(this.muted);
|
|
if (this.hasAttribute("autoplay")) this.play();
|
|
}
|
|
|
|
private _onProbeReady({ duration, adapter, compositionSize }: ProbeResult) {
|
|
this._duration = duration;
|
|
this._directTimelineAdapter = adapter.kind === "direct-timeline" ? adapter.timeline : null;
|
|
this._ready = true;
|
|
this.controlsApi?.updateTime(0, duration);
|
|
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
|
|
if (compositionSize) {
|
|
this._compositionWidth = compositionSize.width;
|
|
this._compositionHeight = compositionSize.height;
|
|
this._rescale();
|
|
}
|
|
try {
|
|
const doc = this.iframe.contentDocument;
|
|
if (doc) this._media.setupFromIframe(doc);
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
this._setIframeMediaMuted(this.muted);
|
|
if (this.hasAttribute("autoplay")) this.play();
|
|
}
|
|
|
|
private _rescale() {
|
|
const applied = scaleIframeToFit(
|
|
this,
|
|
this.iframe,
|
|
this._compositionWidth,
|
|
this._compositionHeight,
|
|
);
|
|
// A no-op before "ready" is expected (element not painted yet). A no-op
|
|
// once ready means the composition is stuck unscaled/untransformed —
|
|
// pinned to the iframe's default top-left position — with no evidence of
|
|
// why in the field. Surface it once (not on every ResizeObserver tick —
|
|
// a legitimately hidden/zero-sized player, e.g. a collapsed tab or
|
|
// off-screen carousel card, would otherwise spam the console forever).
|
|
if (!applied && this._ready && !this._rescaleWarned) {
|
|
this._rescaleWarned = true;
|
|
console.warn("[hyperframes-player] rescale no-op after ready — zero-size player element", {
|
|
src: this.getAttribute("src"),
|
|
offsetWidth: this.offsetWidth,
|
|
offsetHeight: this.offsetHeight,
|
|
compositionWidth: this._compositionWidth,
|
|
compositionHeight: this._compositionHeight,
|
|
});
|
|
}
|
|
}
|
|
|
|
private _onIframeLoad() {
|
|
this._ready = false;
|
|
this._directTimelineAdapter = null;
|
|
this._directTimelineClock.stop();
|
|
this._stopParentTickClock();
|
|
this.shaderLoader.reset();
|
|
this._media.resetForIframeLoad();
|
|
this.probe.start();
|
|
}
|
|
|
|
private _setupControls() {
|
|
if (this.controlsApi) return;
|
|
this.controlsApi = setupControls(
|
|
this.shadow,
|
|
this.muted,
|
|
this._volume,
|
|
this.getAttribute("speed-presets"),
|
|
{
|
|
onPlay: () => this.play(),
|
|
onPause: () => this.pause(),
|
|
onSeek: (f) => this.seek(f * this._duration),
|
|
onScrubStart: () => {
|
|
this._scrubbing = true;
|
|
},
|
|
onScrubEnd: () => {
|
|
this._scrubbing = false;
|
|
// Settle: a normal (silent) seek pauses the proxy audio at the final
|
|
// scrub position, matching the paused playhead.
|
|
this.seek(this._currentTime);
|
|
},
|
|
onSpeedChange: (s) => void (this.playbackRate = s),
|
|
onMuteToggle: () => void (this.muted = !this.muted),
|
|
onVolumeChange: (v) => void (this.volume = v),
|
|
},
|
|
this._isAudioLocked(),
|
|
);
|
|
}
|
|
|
|
// Test-instrumentation pass-throughs (match original field names).
|
|
get _audioOwner() {
|
|
return this._media.audioOwner;
|
|
}
|
|
get _parentMedia() {
|
|
return this._media.entries;
|
|
}
|
|
_mirrorParentMediaTime(t: number, opts?: { force?: boolean }) {
|
|
this._media.mirrorTime(t, opts);
|
|
}
|
|
_promoteToParentProxy() {
|
|
let d: Document | null = null;
|
|
try {
|
|
d = this.iframe.contentDocument;
|
|
} catch {
|
|
/* x-origin */
|
|
}
|
|
this._media.promoteToParentProxy(d, (t, o) => this._mirrorParentMediaTime(t, o));
|
|
this._sendControl("set-media-output-muted", { muted: true });
|
|
}
|
|
_observeDynamicMedia(doc: Document) {
|
|
this._media.setupFromIframe(doc);
|
|
}
|
|
}
|
|
|
|
if (!customElements.get("hyperframes-player")) {
|
|
customElements.define("hyperframes-player", HyperframesPlayer);
|
|
}
|
|
|
|
export { HyperframesPlayer };
|
|
export { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js";
|
|
export type { ControlsCallbacks, ControlsOptions } from "./controls.js";
|
|
export type { ShaderLoadingMode } from "./shader-options.js";
|