Files
hyperframes/packages/player/src/runtime-message-handler.ts
Miguel Ángel a7282dcd0e fix(player): wait for a cross-origin composition's assets before playing (#4103)
* 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
2026-09-18 17:27:16 -04:00

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);
}
}