mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(player): add retained runtime data channels (#3471)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// Types
|
||||
export type { RuntimeTimelineClipIdentity } from "./runtime/types.js";
|
||||
export type {
|
||||
ExecutionMode,
|
||||
Orientation,
|
||||
|
||||
@@ -19,6 +19,8 @@ function createMockDeps() {
|
||||
onSetRootDuration: vi.fn(),
|
||||
onEnablePickMode: vi.fn(),
|
||||
onDisablePickMode: vi.fn(),
|
||||
onSetRuntimeData: vi.fn(),
|
||||
onClearRuntimeData: vi.fn(),
|
||||
getCanonicalFps: vi.fn(() => 30),
|
||||
};
|
||||
}
|
||||
@@ -44,6 +46,16 @@ describe("installRuntimeControlBridge", () => {
|
||||
expect(deps.onPause).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("dispatches set and clear runtime data without global invocation", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
const payload = { version: 3, segments: [] };
|
||||
handler(makeControlMessage("set-runtime-data", { channel: "captions", payload }));
|
||||
handler(makeControlMessage("clear-runtime-data", { channel: "captions" }));
|
||||
expect(deps.onSetRuntimeData).toHaveBeenCalledWith("captions", payload);
|
||||
expect(deps.onClearRuntimeData).toHaveBeenCalledWith("captions");
|
||||
});
|
||||
|
||||
it("dispatches stop-media command", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
|
||||
@@ -28,6 +28,8 @@ type BridgeDeps = {
|
||||
) => void;
|
||||
onEnablePickMode: () => void;
|
||||
onDisablePickMode: () => void;
|
||||
onSetRuntimeData?: (channel: string, payload: unknown) => void;
|
||||
onClearRuntimeData?: (channel: string) => void;
|
||||
getCanonicalFps: () => number;
|
||||
};
|
||||
|
||||
@@ -75,6 +77,12 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
|
||||
"enable-pick-mode": (_d, deps) => deps.onEnablePickMode(),
|
||||
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
|
||||
"flash-elements": (data) => handleFlashElements(data),
|
||||
"set-runtime-data": (data, deps) => {
|
||||
if (typeof data.channel === "string") deps.onSetRuntimeData?.(data.channel, data.payload);
|
||||
},
|
||||
"clear-runtime-data": (data, deps) => {
|
||||
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel);
|
||||
},
|
||||
};
|
||||
|
||||
function resolveSeekTimeSeconds(data: BridgeControlData, deps: BridgeDeps): number {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { installAuthoredOpacityCapture } from "./colorGrading";
|
||||
import { fitTextFontSize } from "../text/fitTextFontSize";
|
||||
import { pretext } from "../text/pretext";
|
||||
import { getVariables } from "./getVariables";
|
||||
import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData";
|
||||
|
||||
type HyperframeWindow = Window & {
|
||||
__hyperframeRuntimeBootstrapped?: boolean;
|
||||
@@ -10,6 +11,9 @@ type HyperframeWindow = Window & {
|
||||
fitTextFontSize: typeof fitTextFontSize;
|
||||
getVariables: typeof getVariables;
|
||||
pretext: typeof pretext;
|
||||
registerRuntimeDataHandler: typeof registerRuntimeDataHandler;
|
||||
setRuntimeData: typeof setRuntimeData;
|
||||
clearRuntimeData: typeof clearRuntimeData;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,6 +33,9 @@ installAuthoredOpacityCapture();
|
||||
fitTextFontSize,
|
||||
getVariables,
|
||||
pretext,
|
||||
registerRuntimeDataHandler,
|
||||
setRuntimeData,
|
||||
clearRuntimeData,
|
||||
};
|
||||
|
||||
function bootstrapHyperframeRuntime(): void {
|
||||
|
||||
@@ -67,6 +67,7 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
|
||||
import { installStudioCustomEase } from "./customEase";
|
||||
import { parseNumeric } from "./startExpression";
|
||||
import { parseStrictFiniteTimingNumber } from "./playbackRate";
|
||||
import { clearRuntimeData, setRuntimeData, setRuntimeDataErrorReporter } from "./runtimeData";
|
||||
|
||||
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
|
||||
const AUTHORED_END_ATTR = "data-hf-authored-end";
|
||||
@@ -132,6 +133,14 @@ export function initSandboxRuntimeModular(): void {
|
||||
// Own the analytics bridge before any best-effort runtime installation so
|
||||
// early failures are observable instead of disappearing before player setup.
|
||||
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
|
||||
setRuntimeDataErrorReporter((channel, error) => {
|
||||
postRuntimeMessage({
|
||||
source: "hf-preview",
|
||||
type: "runtime-data-error",
|
||||
channel,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
// SDK moveElement edits must render even when no usable GSAP timeline ever
|
||||
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
|
||||
// This runs at DOMContentLoaded, after inline composition scripts have
|
||||
@@ -3383,6 +3392,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
},
|
||||
onEnablePickMode: () => picker.enablePickMode(),
|
||||
onDisablePickMode: () => picker.disablePickMode(),
|
||||
onSetRuntimeData: setRuntimeData,
|
||||
onClearRuntimeData: clearRuntimeData,
|
||||
getCanonicalFps: () => state.canonicalFps,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [
|
||||
"rational-fps",
|
||||
"seek-keep-playing",
|
||||
"composition-manifest-v1",
|
||||
"runtime-data",
|
||||
] as const;
|
||||
|
||||
export type RuntimeProtocolFps = {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeData,
|
||||
registerRuntimeDataHandler,
|
||||
resetRuntimeDataForTests,
|
||||
setRuntimeData,
|
||||
setRuntimeDataErrorReporter,
|
||||
} from "./runtimeData";
|
||||
|
||||
describe("runtime data registry", () => {
|
||||
beforeEach(resetRuntimeDataForTests);
|
||||
|
||||
it("delivers retained data when the handler registers later", () => {
|
||||
const handler = vi.fn();
|
||||
setRuntimeData("captions", { words: ["before"] });
|
||||
registerRuntimeDataHandler("captions", handler);
|
||||
expect(handler).toHaveBeenCalledWith({ words: ["before"] });
|
||||
});
|
||||
|
||||
it("replaces handlers and keeps channels isolated", () => {
|
||||
const oldHandler = vi.fn();
|
||||
const newHandler = vi.fn();
|
||||
const other = vi.fn();
|
||||
registerRuntimeDataHandler("captions", oldHandler);
|
||||
registerRuntimeDataHandler("captions", newHandler);
|
||||
registerRuntimeDataHandler("telemetry", other);
|
||||
setRuntimeData("captions", { words: ["latest"] });
|
||||
expect(oldHandler).not.toHaveBeenCalled();
|
||||
expect(newHandler).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies the current handler with undefined when cleared", () => {
|
||||
const handler = vi.fn();
|
||||
registerRuntimeDataHandler("captions", handler);
|
||||
setRuntimeData("captions", { words: [] });
|
||||
clearRuntimeData("captions");
|
||||
expect(handler).toHaveBeenLastCalledWith(undefined);
|
||||
const replacement = vi.fn();
|
||||
registerRuntimeDataHandler("captions", replacement);
|
||||
expect(replacement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports handler exceptions without breaking later delivery", () => {
|
||||
const reporter = vi.fn();
|
||||
setRuntimeDataErrorReporter(reporter);
|
||||
registerRuntimeDataHandler("captions", () => {
|
||||
throw new Error("attach failed");
|
||||
});
|
||||
expect(() => setRuntimeData("captions", {})).not.toThrow();
|
||||
expect(reporter).toHaveBeenCalledWith("captions", expect.any(Error));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export type RuntimeDataHandler = (payload: unknown) => void;
|
||||
export type RuntimeDataErrorReporter = (channel: string, error: unknown) => void;
|
||||
|
||||
const retained = new Map<string, unknown>();
|
||||
const handlers = new Map<string, RuntimeDataHandler>();
|
||||
let reportError: RuntimeDataErrorReporter = () => undefined;
|
||||
|
||||
function validChannel(channel: string): boolean {
|
||||
return /^[a-z][a-z0-9-]{0,63}$/.test(channel);
|
||||
}
|
||||
|
||||
function deliver(channel: string, payload: unknown): void {
|
||||
const handler = handlers.get(channel);
|
||||
if (!handler) return;
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
reportError(channel, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function setRuntimeDataErrorReporter(reporter: RuntimeDataErrorReporter): void {
|
||||
reportError = reporter;
|
||||
}
|
||||
|
||||
export function setRuntimeData(channel: string, payload: unknown): void {
|
||||
if (!validChannel(channel)) return;
|
||||
retained.set(channel, payload);
|
||||
deliver(channel, payload);
|
||||
}
|
||||
|
||||
export function clearRuntimeData(channel: string): void {
|
||||
if (!validChannel(channel)) return;
|
||||
retained.delete(channel);
|
||||
deliver(channel, undefined);
|
||||
}
|
||||
|
||||
export function registerRuntimeDataHandler(
|
||||
channel: string,
|
||||
handler: RuntimeDataHandler,
|
||||
): () => void {
|
||||
if (!validChannel(channel))
|
||||
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
|
||||
handlers.set(channel, handler);
|
||||
if (retained.has(channel)) deliver(channel, retained.get(channel));
|
||||
return () => {
|
||||
if (handlers.get(channel) === handler) handlers.delete(channel);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRuntimeDataForTests(): void {
|
||||
retained.clear();
|
||||
handlers.clear();
|
||||
reportError = () => undefined;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type { HyperframeControlAction } from "../inline-scripts/runtimeContract.
|
||||
import type { HyperframePickerElementInfo } from "../inline-scripts/pickerApi.js";
|
||||
import type { RuntimeProtocolV1 } from "./protocol.js";
|
||||
|
||||
export type RuntimeBridgeControlAction =
|
||||
type RuntimeBridgeControlActionBase =
|
||||
| HyperframeControlAction
|
||||
| "tick"
|
||||
| "set-volume"
|
||||
@@ -24,7 +24,7 @@ export type RuntimeBridgeControlAction =
|
||||
| "stop-media"
|
||||
| "flash-elements";
|
||||
|
||||
export type RuntimeBridgeControlMessage = {
|
||||
type RuntimeBridgeControlMessageBase = {
|
||||
source: "hf-parent";
|
||||
type: "control";
|
||||
action: RuntimeBridgeControlAction;
|
||||
@@ -50,24 +50,27 @@ export type RuntimeStateMessage = {
|
||||
playbackRate: number;
|
||||
};
|
||||
|
||||
export type RuntimeTimelineClip = {
|
||||
export type RuntimeTimelineClipIdentity = {
|
||||
id: string | null;
|
||||
label: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
stackingContextId: string | null;
|
||||
kind: "video" | "audio" | "image" | "element" | "composition";
|
||||
tagName: string | null;
|
||||
compositionId: string | null;
|
||||
compositionAncestors: string[];
|
||||
parentCompositionId: string | null;
|
||||
nodePath: string | null;
|
||||
compositionSrc: string | null;
|
||||
assetUrl: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeTimelineClip = RuntimeTimelineClipIdentity & {
|
||||
zIndex: number;
|
||||
stackingContextId: string | null;
|
||||
compositionAncestors: string[];
|
||||
nodePath: string | null;
|
||||
playbackStart: number;
|
||||
playbackRate: number;
|
||||
assetUrl: string | null;
|
||||
timelineRole: string | null;
|
||||
timelineLabel: string | null;
|
||||
timelineGroup: string | null;
|
||||
@@ -168,6 +171,13 @@ export type RuntimeReadyMessage = {
|
||||
type: "ready";
|
||||
};
|
||||
|
||||
export type RuntimeDataErrorMessage = {
|
||||
source: "hf-preview";
|
||||
type: "runtime-data-error";
|
||||
channel: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics events emitted by the runtime.
|
||||
*
|
||||
@@ -218,6 +228,7 @@ export type RuntimeOutboundMessage =
|
||||
| RuntimeStageSizeMessage
|
||||
| RuntimeMediaAutoplayBlockedMessage
|
||||
| RuntimeReadyMessage
|
||||
| RuntimeDataErrorMessage
|
||||
| RuntimeAnalyticsMessage
|
||||
| RuntimePerformanceMessage
|
||||
| RuntimeGroupLevelsMessage;
|
||||
@@ -317,3 +328,16 @@ export type RuntimeDeterministicAdapter = {
|
||||
export type RuntimeGsapSetTarget = string | Element | Element[] | null;
|
||||
|
||||
export type RuntimeGsapSetVars = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
type RuntimeDataControlFields = {
|
||||
channel?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
type RuntimeBridgeControlAction =
|
||||
| RuntimeBridgeControlActionBase
|
||||
| "set-runtime-data"
|
||||
| "clear-runtime-data";
|
||||
|
||||
export type RuntimeBridgeControlMessage = RuntimeBridgeControlMessageBase &
|
||||
RuntimeDataControlFields;
|
||||
|
||||
+9
@@ -30,6 +30,15 @@ declare global {
|
||||
interface Window {
|
||||
__timelines: Record<string, RuntimeTimelineLike>;
|
||||
__player?: PlayerAPI;
|
||||
__hyperframes?: {
|
||||
registerRuntimeDataHandler?: (
|
||||
channel: string,
|
||||
handler: (payload: unknown) => void,
|
||||
) => () => void;
|
||||
setRuntimeData?: (channel: string, payload: unknown) => void;
|
||||
clearRuntimeData?: (channel: string) => void;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
__clipManifest?: RuntimeTimelineMessage;
|
||||
__clipTree?: ClipTree;
|
||||
__hf?: {
|
||||
|
||||
Reference in New Issue
Block a user