Skip to content

Commit a2482fa

Browse files
committed
feat(player): add retained runtime data channels
1 parent 3e17ddc commit a2482fa

14 files changed

Lines changed: 388 additions & 2 deletions

packages/core/src/runtime/bridge.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function createMockDeps() {
1919
onSetRootDuration: vi.fn(),
2020
onEnablePickMode: vi.fn(),
2121
onDisablePickMode: vi.fn(),
22+
onSetRuntimeData: vi.fn(),
23+
onClearRuntimeData: vi.fn(),
2224
getCanonicalFps: vi.fn(() => 30),
2325
};
2426
}
@@ -44,6 +46,16 @@ describe("installRuntimeControlBridge", () => {
4446
expect(deps.onPause).toHaveBeenCalledOnce();
4547
});
4648

49+
it("dispatches set and clear runtime data without global invocation", () => {
50+
const deps = createMockDeps();
51+
const handler = installRuntimeControlBridge(deps);
52+
const payload = { version: 3, segments: [] };
53+
handler(makeControlMessage("set-runtime-data", { channel: "captions", payload }));
54+
handler(makeControlMessage("clear-runtime-data", { channel: "captions" }));
55+
expect(deps.onSetRuntimeData).toHaveBeenCalledWith("captions", payload);
56+
expect(deps.onClearRuntimeData).toHaveBeenCalledWith("captions");
57+
});
58+
4759
it("dispatches stop-media command", () => {
4860
const deps = createMockDeps();
4961
const handler = installRuntimeControlBridge(deps);

packages/core/src/runtime/bridge.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type BridgeDeps = {
2828
) => void;
2929
onEnablePickMode: () => void;
3030
onDisablePickMode: () => void;
31+
onSetRuntimeData?: (channel: string, payload: unknown) => void;
32+
onClearRuntimeData?: (channel: string) => void;
3133
getCanonicalFps: () => number;
3234
};
3335

@@ -75,6 +77,12 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
7577
"enable-pick-mode": (_d, deps) => deps.onEnablePickMode(),
7678
"disable-pick-mode": (_d, deps) => deps.onDisablePickMode(),
7779
"flash-elements": (data) => handleFlashElements(data),
80+
"set-runtime-data": (data, deps) => {
81+
if (typeof data.channel === "string") deps.onSetRuntimeData?.(data.channel, data.payload);
82+
},
83+
"clear-runtime-data": (data, deps) => {
84+
if (typeof data.channel === "string") deps.onClearRuntimeData?.(data.channel);
85+
},
7886
};
7987

8088
function resolveSeekTimeSeconds(data: BridgeControlData, deps: BridgeDeps): number {

packages/core/src/runtime/entry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ import { installAuthoredOpacityCapture } from "./colorGrading";
33
import { fitTextFontSize } from "../text/fitTextFontSize";
44
import { pretext } from "../text/pretext";
55
import { getVariables } from "./getVariables";
6+
import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData";
67

78
type HyperframeWindow = Window & {
89
__hyperframeRuntimeBootstrapped?: boolean;
910
__hyperframes?: {
1011
fitTextFontSize: typeof fitTextFontSize;
1112
getVariables: typeof getVariables;
1213
pretext: typeof pretext;
14+
registerRuntimeDataHandler: typeof registerRuntimeDataHandler;
15+
setRuntimeData: typeof setRuntimeData;
16+
clearRuntimeData: typeof clearRuntimeData;
1317
};
1418
};
1519

@@ -29,6 +33,9 @@ installAuthoredOpacityCapture();
2933
fitTextFontSize,
3034
getVariables,
3135
pretext,
36+
registerRuntimeDataHandler,
37+
setRuntimeData,
38+
clearRuntimeData,
3239
};
3340

3441
function bootstrapHyperframeRuntime(): void {

packages/core/src/runtime/init.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
6262
import { installStudioCustomEase } from "./customEase";
6363
import { parseNumeric } from "./startExpression";
6464
import { parseStrictFiniteTimingNumber } from "./playbackRate";
65+
import { clearRuntimeData, setRuntimeData, setRuntimeDataErrorReporter } from "./runtimeData";
6566

6667
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
6768
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -127,6 +128,14 @@ export function initSandboxRuntimeModular(): void {
127128
// Own the analytics bridge before any best-effort runtime installation so
128129
// early failures are observable instead of disappearing before player setup.
129130
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
131+
setRuntimeDataErrorReporter((channel, error) => {
132+
postRuntimeMessage({
133+
source: "hf-preview",
134+
type: "runtime-data-error",
135+
channel,
136+
message: error instanceof Error ? error.message : String(error),
137+
});
138+
});
130139
// SDK moveElement edits must render even when no usable GSAP timeline ever
131140
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
132141
// This runs at DOMContentLoaded, after inline composition scripts have
@@ -3318,6 +3327,8 @@ export function initSandboxRuntimeModular(): void {
33183327
},
33193328
onEnablePickMode: () => picker.enablePickMode(),
33203329
onDisablePickMode: () => picker.disablePickMode(),
3330+
onSetRuntimeData: setRuntimeData,
3331+
onClearRuntimeData: clearRuntimeData,
33213332
getCanonicalFps: () => state.canonicalFps,
33223333
});
33233334

packages/core/src/runtime/protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [
55
"rational-fps",
66
"seek-keep-playing",
77
"composition-manifest-v1",
8+
"runtime-data",
89
] as const;
910

1011
export type RuntimeProtocolFps = {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
clearRuntimeData,
4+
registerRuntimeDataHandler,
5+
resetRuntimeDataForTests,
6+
setRuntimeData,
7+
setRuntimeDataErrorReporter,
8+
} from "./runtimeData";
9+
10+
describe("runtime data registry", () => {
11+
beforeEach(resetRuntimeDataForTests);
12+
13+
it("delivers retained data when the handler registers later", () => {
14+
const handler = vi.fn();
15+
setRuntimeData("captions", { words: ["before"] });
16+
registerRuntimeDataHandler("captions", handler);
17+
expect(handler).toHaveBeenCalledWith({ words: ["before"] });
18+
});
19+
20+
it("replaces handlers and keeps channels isolated", () => {
21+
const oldHandler = vi.fn();
22+
const newHandler = vi.fn();
23+
const other = vi.fn();
24+
registerRuntimeDataHandler("captions", oldHandler);
25+
registerRuntimeDataHandler("captions", newHandler);
26+
registerRuntimeDataHandler("telemetry", other);
27+
setRuntimeData("captions", { words: ["latest"] });
28+
expect(oldHandler).not.toHaveBeenCalled();
29+
expect(newHandler).toHaveBeenCalledOnce();
30+
expect(other).not.toHaveBeenCalled();
31+
});
32+
33+
it("notifies the current handler with undefined when cleared", () => {
34+
const handler = vi.fn();
35+
registerRuntimeDataHandler("captions", handler);
36+
setRuntimeData("captions", { words: [] });
37+
clearRuntimeData("captions");
38+
expect(handler).toHaveBeenLastCalledWith(undefined);
39+
const replacement = vi.fn();
40+
registerRuntimeDataHandler("captions", replacement);
41+
expect(replacement).not.toHaveBeenCalled();
42+
});
43+
44+
it("reports handler exceptions without breaking later delivery", () => {
45+
const reporter = vi.fn();
46+
setRuntimeDataErrorReporter(reporter);
47+
registerRuntimeDataHandler("captions", () => {
48+
throw new Error("attach failed");
49+
});
50+
expect(() => setRuntimeData("captions", {})).not.toThrow();
51+
expect(reporter).toHaveBeenCalledWith("captions", expect.any(Error));
52+
});
53+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export type RuntimeDataHandler = (payload: unknown) => void;
2+
export type RuntimeDataErrorReporter = (channel: string, error: unknown) => void;
3+
4+
const retained = new Map<string, unknown>();
5+
const handlers = new Map<string, RuntimeDataHandler>();
6+
let reportError: RuntimeDataErrorReporter = () => undefined;
7+
8+
function validChannel(channel: string): boolean {
9+
return /^[a-z][a-z0-9-]{0,63}$/.test(channel);
10+
}
11+
12+
function deliver(channel: string, payload: unknown): void {
13+
const handler = handlers.get(channel);
14+
if (!handler) return;
15+
try {
16+
handler(payload);
17+
} catch (error) {
18+
reportError(channel, error);
19+
}
20+
}
21+
22+
export function setRuntimeDataErrorReporter(reporter: RuntimeDataErrorReporter): void {
23+
reportError = reporter;
24+
}
25+
26+
export function setRuntimeData(channel: string, payload: unknown): void {
27+
if (!validChannel(channel)) return;
28+
retained.set(channel, payload);
29+
deliver(channel, payload);
30+
}
31+
32+
export function clearRuntimeData(channel: string): void {
33+
if (!validChannel(channel)) return;
34+
retained.delete(channel);
35+
deliver(channel, undefined);
36+
}
37+
38+
export function registerRuntimeDataHandler(
39+
channel: string,
40+
handler: RuntimeDataHandler,
41+
): () => void {
42+
if (!validChannel(channel))
43+
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
44+
handlers.set(channel, handler);
45+
if (retained.has(channel)) deliver(channel, retained.get(channel));
46+
return () => {
47+
if (handlers.get(channel) === handler) handlers.delete(channel);
48+
};
49+
}
50+
51+
export function resetRuntimeDataForTests(): void {
52+
retained.clear();
53+
handlers.clear();
54+
reportError = () => undefined;
55+
}

packages/core/src/runtime/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export type RuntimeBridgeControlAction =
2222
| "set-web-audio-media-disabled"
2323
| "set-root-duration"
2424
| "stop-media"
25-
| "flash-elements";
25+
| "flash-elements"
26+
| "set-runtime-data"
27+
| "clear-runtime-data";
2628

2729
export type RuntimeBridgeControlMessage = {
2830
source: "hf-parent";
@@ -39,6 +41,8 @@ export type RuntimeBridgeControlMessage = {
3941
grading?: RuntimeJson;
4042
compare?: RuntimeJson;
4143
seekMode?: "drag" | "commit";
44+
channel?: string;
45+
payload?: unknown;
4246
};
4347

4448
export type RuntimeStateMessage = {
@@ -168,6 +172,13 @@ export type RuntimeReadyMessage = {
168172
type: "ready";
169173
};
170174

175+
export type RuntimeDataErrorMessage = {
176+
source: "hf-preview";
177+
type: "runtime-data-error";
178+
channel: string;
179+
message: string;
180+
};
181+
171182
/**
172183
* Analytics events emitted by the runtime.
173184
*
@@ -218,6 +229,7 @@ export type RuntimeOutboundMessage =
218229
| RuntimeStageSizeMessage
219230
| RuntimeMediaAutoplayBlockedMessage
220231
| RuntimeReadyMessage
232+
| RuntimeDataErrorMessage
221233
| RuntimeAnalyticsMessage
222234
| RuntimePerformanceMessage
223235
| RuntimeGroupLevelsMessage;

packages/core/src/runtime/window.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ declare global {
3030
interface Window {
3131
__timelines: Record<string, RuntimeTimelineLike>;
3232
__player?: PlayerAPI;
33+
__hyperframes?: {
34+
registerRuntimeDataHandler?: (
35+
channel: string,
36+
handler: (payload: unknown) => void,
37+
) => () => void;
38+
setRuntimeData?: (channel: string, payload: unknown) => void;
39+
clearRuntimeData?: (channel: string) => void;
40+
[key: string]: unknown;
41+
};
3342
__clipManifest?: RuntimeTimelineMessage;
3443
__clipTree?: ClipTree;
3544
__hf?: {

0 commit comments

Comments
 (0)