From 4807af59f2e6482289da8a340d18334eced974ec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 24 Aug 2026 09:53:05 -0700 Subject: [PATCH] feat(player): add retained runtime data channels --- packages/core/src/index.ts | 1 + packages/core/src/runtime/bridge.test.ts | 12 ++ packages/core/src/runtime/bridge.ts | 8 ++ packages/core/src/runtime/entry.ts | 7 ++ packages/core/src/runtime/init.ts | 11 ++ packages/core/src/runtime/protocol.ts | 1 + packages/core/src/runtime/runtimeData.test.ts | 53 ++++++++ packages/core/src/runtime/runtimeData.ts | 55 +++++++++ packages/core/src/runtime/types.ts | 40 ++++-- packages/core/src/runtime/window.d.ts | 9 ++ .../player/src/hyperframes-player.test.ts | 114 ++++++++++++++++++ packages/player/src/hyperframes-player.ts | 79 +++++++++++- .../src/runtime-message-handler.test.ts | 23 ++++ .../player/src/runtime-message-handler.ts | 9 ++ packages/player/vitest.config.ts | 5 + .../studio/src/player/lib/playbackTypes.ts | 15 +-- .../src/player/lib/runtimeProtocol.test.ts | 1 + 17 files changed, 422 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/runtime/runtimeData.test.ts create mode 100644 packages/core/src/runtime/runtimeData.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7081a348d6..778586d941 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ // Types +export type { RuntimeTimelineClipIdentity } from "./runtime/types.js"; export type { ExecutionMode, Orientation, diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 7f54ebfda4..60cbd042df 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -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); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index cb718647ac..804bde0539 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -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 = { "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 { diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 0227095203..0d1e95f219 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -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 { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d968f4279f..831fccce11 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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"; @@ -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 @@ -3318,6 +3327,8 @@ export function initSandboxRuntimeModular(): void { }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), + onSetRuntimeData: setRuntimeData, + onClearRuntimeData: clearRuntimeData, getCanonicalFps: () => state.canonicalFps, }); diff --git a/packages/core/src/runtime/protocol.ts b/packages/core/src/runtime/protocol.ts index 6c2af46250..653d9f6176 100644 --- a/packages/core/src/runtime/protocol.ts +++ b/packages/core/src/runtime/protocol.ts @@ -5,6 +5,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [ "rational-fps", "seek-keep-playing", "composition-manifest-v1", + "runtime-data", ] as const; export type RuntimeProtocolFps = { diff --git a/packages/core/src/runtime/runtimeData.test.ts b/packages/core/src/runtime/runtimeData.test.ts new file mode 100644 index 0000000000..60efc255ee --- /dev/null +++ b/packages/core/src/runtime/runtimeData.test.ts @@ -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)); + }); +}); diff --git a/packages/core/src/runtime/runtimeData.ts b/packages/core/src/runtime/runtimeData.ts new file mode 100644 index 0000000000..d088bd8be7 --- /dev/null +++ b/packages/core/src/runtime/runtimeData.ts @@ -0,0 +1,55 @@ +export type RuntimeDataHandler = (payload: unknown) => void; +export type RuntimeDataErrorReporter = (channel: string, error: unknown) => void; + +const retained = new Map(); +const handlers = new Map(); +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; +} diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 31626bff39..5e98822fa5 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -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; + +type RuntimeDataControlFields = { + channel?: string; + payload?: unknown; +}; + +type RuntimeBridgeControlAction = + | RuntimeBridgeControlActionBase + | "set-runtime-data" + | "clear-runtime-data"; + +export type RuntimeBridgeControlMessage = RuntimeBridgeControlMessageBase & + RuntimeDataControlFields; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 212a69af25..ce9ab530c7 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -30,6 +30,15 @@ declare global { interface Window { __timelines: Record; __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?: { diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index ae853eb8e5..8b7fadc7a5 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -2387,3 +2387,117 @@ describe("HyperframesPlayer composition dimension attributes", () => { expect(player._compositionWidth).toBe(1920); }); }); + +describe("HyperframesPlayer retained runtime data", () => { + interface RuntimeDataPlayer extends HTMLElement { + iframeElement: HTMLIFrameElement; + setRuntimeData: (channel: string, payload: unknown) => void; + clearRuntimeData: (channel: string) => void; + _onMessage: (event: MessageEvent) => void; + } + + let player: RuntimeDataPlayer; + let postSpy: ReturnType; + + const readyMessage = () => + new MessageEvent("message", { + source: window, + data: { source: "hf-preview", type: "ready" }, + }); + + const runtimeCalls = () => + postSpy.mock.calls.filter((call) => { + const message = call[0] as { action?: string }; + return message.action === "set-runtime-data" || message.action === "clear-runtime-data"; + }); + + beforeEach(async () => { + await import("./hyperframes-player.js"); + player = document.createElement("hyperframes-player") as RuntimeDataPlayer; + postSpy = vi.spyOn(window, "postMessage").mockImplementation(() => undefined); + Object.defineProperty(player.iframeElement, "contentWindow", { + configurable: true, + get: () => window, + }); + delete (window as Window & { __hyperframes?: unknown }).__hyperframes; + document.body.appendChild(player); + }); + + afterEach(() => { + player.remove(); + delete (window as Window & { __hyperframes?: unknown }).__hyperframes; + vi.restoreAllMocks(); + }); + + it("retains data set before load and replays it exactly once after runtime ready", () => { + player.setRuntimeData("captions", { words: ["before"] }); + expect(runtimeCalls()).toHaveLength(0); + + player._onMessage(readyMessage()); + + expect(runtimeCalls()).toHaveLength(1); + expect(runtimeCalls()[0]?.[0]).toMatchObject({ + action: "set-runtime-data", + channel: "captions", + payload: { words: ["before"] }, + }); + }); + + it("delivers after readiness and replays only the latest value after a source swap", () => { + player._onMessage(readyMessage()); + player.setRuntimeData("captions", { words: ["first"] }); + postSpy.mockClear(); + + player.setAttribute("srcdoc", ""); + player.setRuntimeData("captions", { words: ["latest"] }); + expect(runtimeCalls()).toHaveLength(0); + player._onMessage(readyMessage()); + + expect(runtimeCalls()).toHaveLength(1); + expect(runtimeCalls()[0]?.[0]).toMatchObject({ payload: { words: ["latest"] } }); + }); + + it("clears the current channel and does not replay it", () => { + player._onMessage(readyMessage()); + player.setRuntimeData("captions", { words: [] }); + player.clearRuntimeData("captions"); + expect(runtimeCalls().at(-1)?.[0]).toMatchObject({ + action: "clear-runtime-data", + channel: "captions", + }); + postSpy.mockClear(); + player.setAttribute("srcdoc", ""); + player._onMessage(readyMessage()); + expect(runtimeCalls()).toHaveLength(0); + }); + + it("uses the same-origin registry directly and falls back to postMessage otherwise", () => { + const direct = vi.fn(); + (window as Window & { __hyperframes?: unknown }).__hyperframes = { + setRuntimeData: direct, + }; + player._onMessage(readyMessage()); + postSpy.mockClear(); + + player.setRuntimeData("captions", { words: ["direct"] }); + + expect(direct).toHaveBeenCalledWith("captions", { words: ["direct"] }); + expect(runtimeCalls()).toHaveLength(0); + }); + + it("does not deliver while disconnected and preserves the standard sandbox", () => { + player._onMessage(readyMessage()); + postSpy.mockClear(); + player.remove(); + player.setRuntimeData("captions", { words: ["offline"] }); + expect(runtimeCalls()).toHaveLength(0); + expect(player.iframeElement.sandbox.contains("allow-scripts")).toBe(true); + expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(true); + expect(player.iframeElement.sandbox.contains("allow-top-navigation")).toBe(false); + expect(player.iframeElement.referrerPolicy).toBe("no-referrer"); + }); + + it("rejects payloads that structuredClone cannot transfer", () => { + expect(() => player.setRuntimeData("captions", () => undefined)).toThrow(); + }); +}); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index d1764a5e7e..94d1a4d277 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -46,6 +46,11 @@ export type ColorGradingCompareState = { lineWidth?: number; }; +type RuntimeDataBridge = { + setRuntimeData?: (channel: string, payload: unknown) => void; + clearRuntimeData?: (channel: string) => void; +}; + function clampPlaybackRate(rate: number): number { if (!Number.isFinite(rate) || rate <= 0) return 1; return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate)); @@ -97,6 +102,8 @@ class HyperframesPlayer extends HTMLElement { private _media: ParentMediaManager; private _scenes: { id: string; start: number; duration: number }[] = []; private _runtimeFps = 30; + private _runtimeBridgeReady = false; + private _runtimeData = new Map(); constructor() { super(); @@ -192,6 +199,7 @@ class HyperframesPlayer extends HTMLElement { this.controlsApi = null; this._paused = true; this._ready = false; + this._runtimeBridgeReady = false; } // fallow-ignore-next-line complexity @@ -200,11 +208,13 @@ class HyperframesPlayer extends HTMLElement { case "src": if (val) { this._ready = false; + this._runtimeBridgeReady = false; this.iframe.src = prepareSrcForElement(this, val); } break; case "srcdoc": this._ready = false; + this._runtimeBridgeReady = false; if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val); else this.iframe.removeAttribute("srcdoc"); break; @@ -380,6 +390,24 @@ class HyperframesPlayer extends HTMLElement { }); } + /** Retain and deliver structured runtime data through the shared runtime protocol. */ + setRuntimeData(channel: string, payload: unknown): void { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) { + throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`); + } + const retained = typeof structuredClone === "function" ? structuredClone(payload) : payload; + this._runtimeData.set(channel, retained); + this._deliverRuntimeData(channel, retained); + } + + clearRuntimeData(channel: string): void { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) { + throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`); + } + this._runtimeData.delete(channel); + this._deliverRuntimeDataClear(channel); + } + get currentTime() { return this._currentTime; } @@ -518,6 +546,50 @@ class HyperframesPlayer extends HTMLElement { } } + private _deliverRuntimeData(channel: string, payload: unknown): void { + if (!this.isConnected || !this._runtimeBridgeReady) return; + if (this._trySetRuntimeDataDirect(channel, payload)) return; + this._sendControl("set-runtime-data", { channel, payload }); + } + + private _deliverRuntimeDataClear(channel: string): void { + if (!this.isConnected || !this._runtimeBridgeReady) return; + if (this._tryClearRuntimeDataDirect(channel)) return; + this._sendControl("clear-runtime-data", { channel }); + } + + private _trySetRuntimeDataDirect(channel: string, payload: unknown): boolean { + try { + const bridge = ( + this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null + )?.__hyperframes; + if (typeof bridge?.setRuntimeData !== "function") return false; + bridge.setRuntimeData(channel, payload); + return true; + } catch { + return false; + } + } + + private _tryClearRuntimeDataDirect(channel: string): boolean { + try { + const bridge = ( + this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null + )?.__hyperframes; + if (typeof bridge?.clearRuntimeData !== "function") return false; + bridge.clearRuntimeData(channel); + return true; + } catch { + return false; + } + } + + private _replayRuntimeData(): void { + for (const [channel, payload] of this._runtimeData) { + this._deliverRuntimeData(channel, payload); + } + } + /** * Returns the iframe's contentDocument if same-origin and reachable, * otherwise null. Accessing contentDocument can throw on cross-origin @@ -672,7 +744,11 @@ class HyperframesPlayer extends HTMLElement { }, sendControl: (action, extra) => this._sendControl(action, extra), getIframeDoc: () => this.iframe.contentDocument, - onRuntimeReady: () => this._replayBridgeState(), + onRuntimeReady: () => { + this._runtimeBridgeReady = true; + this._replayBridgeState(); + this._replayRuntimeData(); + }, onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration), setRuntimeFps: (fps) => { this._runtimeFps = fps; @@ -761,6 +837,7 @@ class HyperframesPlayer extends HTMLElement { private _onIframeLoad() { this._ready = false; + this._runtimeBridgeReady = false; this._directTimelineAdapter = null; this._directTimelineClock.stop(); this._stopParentTickClock(); diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index e56269973b..f463c5f65c 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -71,6 +71,29 @@ describe("handleRuntimeMessage stage-size", () => { }); }); +describe("handleRuntimeMessage runtime data errors", () => { + it("surfaces a channel-scoped player event", () => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + handleRuntimeMessage( + { + source: frameWindow, + data: { + source: "hf-preview", + type: "runtime-data-error", + channel: "captions", + message: "attach failed", + }, + } as MessageEvent, + frameWindow, + callbacks, + ); + expect(callbacks.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "runtimedataerror" }), + ); + }); +}); + describe("handleRuntimeMessage media autoplay fallback", () => { const autoplayBlockedEvent = (source: object): MessageEvent => ({ diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index 76b6421d1b..47d1335f68 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -92,6 +92,15 @@ export function handleRuntimeMessage( return; } + if (data["type"] === "runtime-data-error") { + callbacks.dispatchEvent( + new CustomEvent("runtimedataerror", { + detail: { channel: data["channel"], message: data["message"] }, + }), + ); + return; + } + if (data["type"] === "state") { callbacks.setPlaybackState( applyRuntimeStateMessage( diff --git a/packages/player/vitest.config.ts b/packages/player/vitest.config.ts index 98c4399003..efdfc71735 100644 --- a/packages/player/vitest.config.ts +++ b/packages/player/vitest.config.ts @@ -9,6 +9,11 @@ const coreRoot = resolve(fileURLToPath(new URL("../core/src", import.meta.url))) export default defineConfig({ resolve: { alias: { + "@hyperframes/core/composition-contract": resolve(coreRoot, "compositionContract.ts"), + "@hyperframes/parsers/composition-contract": resolve( + coreRoot, + "../../parsers/src/compositionContract.ts", + ), "@hyperframes/core/slideshow": resolve(coreRoot, "slideshow/index.ts"), "@hyperframes/core/runtime/protocol": resolve(coreRoot, "runtime/protocol.ts"), }, diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index 76bfd1c344..eb4aa62067 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -4,6 +4,8 @@ * from here without creating circular dependencies. */ +import type { RuntimeTimelineClipIdentity } from "@hyperframes/core"; + export interface PlaybackAdapter { play: () => void; pause: () => void; @@ -32,23 +34,12 @@ export interface TimelineLike { isActive: () => boolean; } -export interface ClipManifestClip { - id: string | null; - label: string; - start: number; - duration: number; - track: number; +export interface ClipManifestClip extends RuntimeTimelineClipIdentity { zIndex?: number; stackingContextId?: string | null; - kind: "video" | "audio" | "image" | "element" | "composition"; - tagName: string | null; - compositionId: string | null; compositionAncestors?: string[]; - parentCompositionId: string | null; - compositionSrc: string | null; playbackStart?: number; playbackRate?: number; - assetUrl: string | null; } export interface ClipManifest { diff --git a/packages/studio/src/player/lib/runtimeProtocol.test.ts b/packages/studio/src/player/lib/runtimeProtocol.test.ts index 21a6811695..6a13501def 100644 --- a/packages/studio/src/player/lib/runtimeProtocol.test.ts +++ b/packages/studio/src/player/lib/runtimeProtocol.test.ts @@ -19,6 +19,7 @@ describe("Studio runtime protocol", () => { "rational-fps", "seek-keep-playing", "composition-manifest-v1", + "runtime-data", ], fps: { numerator: 60, denominator: 1 }, timeSeconds: 1.25,