mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 22:57:12 +00:00
* fix(player): wait for a cross-origin composition's assets before playing The player's readiness gate scans the iframe document, which is null when the composition is embedded from another origin, so it released playback at once and the first frames could play with images still loading. The runtime now runs the same readiness scan inside the iframe and posts assets-ready; its timeline message reports whether assets have settled so an older runtime, which never posts it, is not waited on. Also declares the animation-frame abort handler before a host whose requestAnimationFrame calls back synchronously can reach it. * fix(player): keep a ready cross-origin player ready across a late iframe load The iframe load event reset readiness for any document the player cannot read. The runtime posts its timeline before load and a paused runtime never posts it again, so a load arriving after the assets settled left the player not ready. A ready cross-origin player already holds this document's handshake, because every navigation clears readiness first. * fix(core): stop RuntimeReadinessWindow widening Window.__hf so the runtime typecheck passes * fix(core): a torn-down runtime no longer posts assets-ready
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
/**
|
|
* 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";
|
|
import { inspectRuntimeProtocol } from "@hyperframes/core/runtime/protocol";
|
|
|
|
type SceneRecord = { id: string; start: number; duration: number };
|
|
|
|
function extractScenes(raw: unknown): SceneRecord[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.filter(
|
|
(s): s is SceneRecord =>
|
|
typeof s === "object" &&
|
|
s !== null &&
|
|
typeof (s as Record<string, unknown>)["id"] === "string" &&
|
|
typeof (s as Record<string, unknown>)["start"] === "number" &&
|
|
typeof (s as Record<string, unknown>)["duration"] === "number",
|
|
);
|
|
}
|
|
|
|
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;
|
|
/** Invoked when the iframe runtime posts `{type: "ready"}` — the player
|
|
* uses it to replay current bridge state (mute, volume, playback rate) so
|
|
* control messages sent before the iframe's listener registered aren't lost. */
|
|
onRuntimeReady: () => void;
|
|
/** Invoked when the runtime reports its composition assets settled. */
|
|
onRuntimeAssetsReady?: (timedOut: boolean) => void;
|
|
onRuntimeDataApplied?: (channel: unknown, requestId: unknown) => void;
|
|
onRuntimeDataError?: (channel: unknown, requestId: unknown, message: unknown) => void;
|
|
/** Invoked when the runtime posts a finite positive timeline duration. The
|
|
* player uses this as the cross-origin readiness signal because the
|
|
* same-origin composition probe cannot inspect CDN iframes. */
|
|
onRuntimeTimelineReady: (duration: number, assetsReady: boolean | undefined) => void;
|
|
setRuntimeFps?: (fps: number) => void;
|
|
/** Called with the scene list whenever a "timeline" message is received. */
|
|
setScenes: (scenes: SceneRecord[]) => void;
|
|
/** Return false to ignore the iframe runtime's audible-media autoplay fallback.
|
|
* Slideshow embeds keep iframe media under native element ownership because
|
|
* presenter/audience sync mirrors those media events directly. */
|
|
shouldPromoteMediaAutoplayFallback?: () => boolean;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
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;
|
|
const protocol = inspectRuntimeProtocol(data);
|
|
if (protocol.status === "unsupported") {
|
|
callbacks.dispatchEvent(
|
|
new CustomEvent("runtimeprotocolerror", {
|
|
detail: { code: protocol.code, receivedVersion: protocol.receivedVersion },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
callbacks.setRuntimeFps?.(protocol.fps);
|
|
|
|
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"] === "ready") {
|
|
callbacks.onRuntimeReady();
|
|
return;
|
|
}
|
|
|
|
if (data["type"] === "assets-ready") {
|
|
callbacks.onRuntimeAssetsReady?.(data["timedOut"] === true);
|
|
return;
|
|
}
|
|
|
|
if (data["type"] === "runtime-data-error") {
|
|
callbacks.onRuntimeDataError?.(data["channel"], data["requestId"], data["message"]);
|
|
return;
|
|
}
|
|
|
|
if (data["type"] === "runtime-data-applied") {
|
|
callbacks.onRuntimeDataApplied?.(data["channel"], data["requestId"]);
|
|
return;
|
|
}
|
|
|
|
if (data["type"] === "state") {
|
|
callbacks.setPlaybackState(
|
|
applyRuntimeStateMessage(
|
|
{ frame: (data["frame"] as number) ?? 0, isPlaying: !!data["isPlaying"] },
|
|
protocol.fps,
|
|
callbacks.getPlaybackState(),
|
|
callbacks,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (data["type"] === "media-autoplay-blocked") {
|
|
if (callbacks.shouldPromoteMediaAutoplayFallback?.() === false) return;
|
|
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) {
|
|
const declaredDuration = Number(data["durationSeconds"]);
|
|
const frameDuration = Number(data["durationInFrames"]);
|
|
const duration =
|
|
Number.isFinite(declaredDuration) && declaredDuration > 0
|
|
? declaredDuration
|
|
: frameDuration / protocol.fps;
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
const pb = callbacks.getPlaybackState();
|
|
callbacks.setPlaybackState({ ...pb, duration });
|
|
callbacks.updateControlsTime(pb.currentTime, duration);
|
|
callbacks.onRuntimeTimelineReady(
|
|
duration,
|
|
typeof data["assetsReady"] === "boolean" ? data["assetsReady"] : undefined,
|
|
);
|
|
}
|
|
if (
|
|
Number.isFinite(data["compositionWidth"]) &&
|
|
(data["compositionWidth"] as number) > 0 &&
|
|
Number.isFinite(data["compositionHeight"]) &&
|
|
(data["compositionHeight"] as number) > 0
|
|
) {
|
|
callbacks.setCompositionSize(
|
|
data["compositionWidth"] as number,
|
|
data["compositionHeight"] as number,
|
|
);
|
|
}
|
|
callbacks.setScenes(extractScenes(data["scenes"]));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data["type"] === "stage-size" &&
|
|
// Finite-check like the timeline branch above: `> 0` alone lets
|
|
// Infinity through, which scales the iframe to 0 and blanks it.
|
|
Number.isFinite(data["width"]) &&
|
|
(data["width"] as number) > 0 &&
|
|
Number.isFinite(data["height"]) &&
|
|
(data["height"] as number) > 0
|
|
) {
|
|
callbacks.setCompositionSize(data["width"] as number, data["height"] as number);
|
|
}
|
|
}
|