mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
fix(player): version runtime protocol
This commit is contained in:
@@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => {
|
||||
unmountWithAct(root);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not settle from an unsupported runtime protocol message", () => {
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const iframe = document.createElement("iframe");
|
||||
const iframeWindow = {
|
||||
postMessage: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as Record<string, unknown>;
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: iframeWindow,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
api.iframeRef.current = iframe;
|
||||
api.onIframeLoad();
|
||||
});
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
||||
|
||||
iframeWindow.__player = {
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
seek: vi.fn(),
|
||||
getTime: () => 0,
|
||||
getDuration: () => 30,
|
||||
isPlaying: () => false,
|
||||
};
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow as unknown as Window,
|
||||
data: { source: "hf-preview", type: "state", protocolVersion: 999 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow as unknown as Window,
|
||||
data: { source: "hf-preview", type: "state" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
||||
|
||||
unmountWithAct(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelinePlayer audio controls (#835)", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||
|
||||
/**
|
||||
* Whether the derived elements differ from the current ones in any field that
|
||||
@@ -492,6 +493,9 @@ export function useTimelinePlayer() {
|
||||
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (data?.source === "hf-preview") {
|
||||
if (!acceptStudioRuntimeMessage(data)) return;
|
||||
}
|
||||
if (data?.source === "hf-preview" && data?.type === "state") {
|
||||
try {
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
autoHealMissingCompositionIds,
|
||||
buildMissingCompositionElements,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||
|
||||
interface UseTimelineSyncCallbacksParams {
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
@@ -132,6 +133,9 @@ export function useTimelineSyncCallbacks({
|
||||
clips: ClipManifestClip[];
|
||||
durationInFrames: number;
|
||||
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
||||
protocolVersion?: unknown;
|
||||
capabilities?: unknown;
|
||||
fps?: unknown;
|
||||
}) => {
|
||||
if (!data.clips || data.clips.length === 0) {
|
||||
return;
|
||||
@@ -222,7 +226,7 @@ export function useTimelineSyncCallbacks({
|
||||
hostEl,
|
||||
});
|
||||
});
|
||||
const rawDuration = data.durationInFrames / 30;
|
||||
const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
|
||||
// Clamp non-finite or absurdly large durations — the runtime can emit
|
||||
// Infinity when it detects a loop-inflated GSAP timeline without an
|
||||
// explicit data-duration on the root composition. Floor the manifest total
|
||||
@@ -418,6 +422,10 @@ export function useTimelineSyncCallbacks({
|
||||
if (e.source && iframe && e.source !== iframe.contentWindow) return;
|
||||
const data = e.data;
|
||||
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
||||
// The main message handler owns protocol-error diagnostics. This readiness-only
|
||||
// listener mirrors its acceptance gate without dispatching a duplicate event:
|
||||
// an unsupported runtime must not make the iframe appear successfully settled.
|
||||
if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
|
||||
trySettle();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ export { resolveIframe } from "./lib/timelineDOM";
|
||||
|
||||
// Store
|
||||
export { usePlayerStore, liveTime } from "./store/playerStore";
|
||||
export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
|
||||
// Public library surface; external consumers are invisible to the workspace analyzer.
|
||||
// fallow-ignore-next-line unused-exports
|
||||
export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
|
||||
|
||||
// Utils
|
||||
export { formatTime } from "./lib/time";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
acceptStudioRuntimeMessage,
|
||||
acceptedRuntimeMessageFps,
|
||||
createRuntimeControlMessage,
|
||||
inspectStudioRuntimeMessage,
|
||||
postRuntimeControlMessage,
|
||||
} from "./runtimeProtocol";
|
||||
|
||||
describe("Studio runtime protocol", () => {
|
||||
it("versions every control message and declares rational fps", () => {
|
||||
expect(createRuntimeControlMessage("seek", { timeSeconds: 1.25 }, 60)).toEqual({
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "seek",
|
||||
protocolVersion: 1,
|
||||
capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
|
||||
fps: { numerator: 60, denominator: 1 },
|
||||
timeSeconds: 1.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("posts the typed message to the target window", () => {
|
||||
const target = { postMessage: vi.fn() };
|
||||
postRuntimeControlMessage(target as unknown as Window, "pause");
|
||||
expect(target.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "pause", protocolVersion: 1 }),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy 30fps messages and rejects unknown majors", () => {
|
||||
expect(inspectStudioRuntimeMessage({ source: "hf-preview" })).toEqual({
|
||||
status: "legacy",
|
||||
fps: 30,
|
||||
});
|
||||
expect(inspectStudioRuntimeMessage({ protocolVersion: 2 })).toMatchObject({
|
||||
status: "unsupported",
|
||||
code: "unsupported_protocol_version",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads explicit fps for accepted timeline messages", () => {
|
||||
const message = createRuntimeControlMessage("pause", {}, 60);
|
||||
expect(acceptedRuntimeMessageFps(message)).toBe(60);
|
||||
expect(acceptStudioRuntimeMessage(message)).toMatchObject({ status: "supported", fps: 60 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
inspectRuntimeProtocol,
|
||||
runtimeProtocolMetadata,
|
||||
type RuntimeProtocolInspection,
|
||||
} from "@hyperframes/core/runtime/protocol";
|
||||
|
||||
export type RuntimeControlMessage = {
|
||||
source: "hf-parent";
|
||||
type: "control";
|
||||
action: string;
|
||||
} & ReturnType<typeof runtimeProtocolMetadata> &
|
||||
Record<string, unknown>;
|
||||
|
||||
export function createRuntimeControlMessage(
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
fps = 30,
|
||||
): RuntimeControlMessage {
|
||||
return {
|
||||
...payload,
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action,
|
||||
...runtimeProtocolMetadata(fps),
|
||||
};
|
||||
}
|
||||
|
||||
export function postRuntimeControlMessage(
|
||||
target: Pick<Window, "postMessage"> | null | undefined,
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
fps = 30,
|
||||
): void {
|
||||
target?.postMessage(createRuntimeControlMessage(action, payload, fps), "*");
|
||||
}
|
||||
|
||||
export function inspectStudioRuntimeMessage(value: unknown): RuntimeProtocolInspection {
|
||||
return inspectRuntimeProtocol(value, 30);
|
||||
}
|
||||
|
||||
function dispatchRuntimeProtocolError(inspection: RuntimeProtocolInspection): void {
|
||||
if (inspection.status !== "unsupported") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("runtimeprotocolerror", {
|
||||
detail: {
|
||||
code: inspection.code,
|
||||
receivedVersion: inspection.receivedVersion,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptStudioRuntimeMessage(
|
||||
value: unknown,
|
||||
): Exclude<RuntimeProtocolInspection, { status: "unsupported" }> | null {
|
||||
const inspection = inspectStudioRuntimeMessage(value);
|
||||
if (inspection.status !== "unsupported") return inspection;
|
||||
dispatchRuntimeProtocolError(inspection);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function acceptedRuntimeMessageFps(value: unknown): number {
|
||||
const inspection = inspectStudioRuntimeMessage(value);
|
||||
return inspection.status === "supported" ? inspection.fps : 30;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildTimelineElementIdentity,
|
||||
readTimelineElementZIndex,
|
||||
} from "./timelineElementHelpers";
|
||||
import { postRuntimeControlMessage } from "./runtimeProtocol";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport / DOM normalisation
|
||||
@@ -103,10 +104,7 @@ function postPreviewControl(
|
||||
action: string,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action, ...payload },
|
||||
"*",
|
||||
);
|
||||
postRuntimeControlMessage(iframe.contentWindow, action, payload);
|
||||
}
|
||||
|
||||
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user