feat(player): add retained runtime data channels (#3471)

This commit is contained in:
Vance Ingalls
2026-08-26 07:48:00 +00:00
committed by GitHub
parent 9aaa7552fc
commit 4f00336c92
17 changed files with 422 additions and 21 deletions
+1
View File
@@ -1,4 +1,5 @@
// Types
export type { RuntimeTimelineClipIdentity } from "./runtime/types.js";
export type {
ExecutionMode,
Orientation,
+12
View File
@@ -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);
+8
View File
@@ -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 {
+7
View File
@@ -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 {
+11
View File
@@ -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,
});
+1
View File
@@ -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));
});
});
+55
View File
@@ -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;
}
+32 -8
View File
@@ -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
View File
@@ -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?: {