Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Types
export type { RuntimeTimelineClipIdentity } from "./runtime/types.js";
export type {
ExecutionMode,
Orientation,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type BridgeDeps = {
) => void;
onEnablePickMode: () => void;
onDisablePickMode: () => void;
onSetRuntimeData?: (channel: string, payload: unknown) => void;
onClearRuntimeData?: (channel: string) => void;
getCanonicalFps: () => number;
};

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ 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;
__hyperframes?: {
fitTextFontSize: typeof fitTextFontSize;
getVariables: typeof getVariables;
pretext: typeof pretext;
registerRuntimeDataHandler: typeof registerRuntimeDataHandler;
setRuntimeData: typeof setRuntimeData;
clearRuntimeData: typeof clearRuntimeData;
};
};

Expand All @@ -29,6 +33,9 @@ installAuthoredOpacityCapture();
fitTextFontSize,
getVariables,
pretext,
registerRuntimeDataHandler,
setRuntimeData,
clearRuntimeData,
};

function bootstrapHyperframeRuntime(): void {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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";
Expand Down Expand Up @@ -127,6 +128,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
Expand Down Expand Up @@ -3318,6 +3327,8 @@ export function initSandboxRuntimeModular(): void {
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
onSetRuntimeData: setRuntimeData,
onClearRuntimeData: clearRuntimeData,
getCanonicalFps: () => state.canonicalFps,
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [
"rational-fps",
"seek-keep-playing",
"composition-manifest-v1",
"runtime-data",
] as const;

export type RuntimeProtocolFps = {
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/runtime/runtimeData.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 55 additions & 0 deletions packages/core/src/runtime/runtimeData.ts
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 32 additions & 8 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,7 +24,7 @@ export type RuntimeBridgeControlAction =
| "stop-media"
| "flash-elements";

export type RuntimeBridgeControlMessage = {
type RuntimeBridgeControlMessageBase = {
source: "hf-parent";
type: "control";
action: RuntimeBridgeControlAction;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -218,6 +228,7 @@ export type RuntimeOutboundMessage =
| RuntimeStageSizeMessage
| RuntimeMediaAutoplayBlockedMessage
| RuntimeReadyMessage
| RuntimeDataErrorMessage
| RuntimeAnalyticsMessage
| RuntimePerformanceMessage
| RuntimeGroupLevelsMessage;
Expand Down Expand Up @@ -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 changes: 9 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
Loading
Loading