diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 4f5f8f6b28..7c85694fc3 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -90,6 +90,12 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ + // Public player-state types are consumed by package users outside this + // workspace. Keep the barrel stable even when no in-repo import needs it. + { + "file": "packages/studio/src/player/index.ts", + "exports": ["ZoomMode"], + }, // Part of useTimelineEditing's inferred public return type; consumers invoke // handleTimelineGroupResize without importing the change type directly. { diff --git a/packages/core/package.json b/packages/core/package.json index fe4659d933..85ac59c276 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -107,6 +107,12 @@ "import": "./src/runtime/startExpression.ts", "types": "./src/runtime/startExpression.ts" }, + "./runtime/protocol": { + "bun": "./src/runtime/protocol.ts", + "node": "./dist/runtime/protocol.js", + "import": "./src/runtime/protocol.ts", + "types": "./src/runtime/protocol.ts" + }, "./compiler/html-document": { "bun": "./src/compiler/htmlDocument.ts", "node": "./dist/compiler/htmlDocument.js", @@ -307,6 +313,10 @@ "import": "./dist/runtime/startExpression.js", "types": "./dist/runtime/startExpression.d.ts" }, + "./runtime/protocol": { + "import": "./dist/runtime/protocol.js", + "types": "./dist/runtime/protocol.d.ts" + }, "./compiler/html-document": { "import": "./dist/compiler/htmlDocument.js", "types": "./dist/compiler/htmlDocument.d.ts" diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 7e58cdfcec..e5d432dbc4 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -19,6 +19,7 @@ function createMockDeps() { onSetRootDuration: vi.fn(), onEnablePickMode: vi.fn(), onDisablePickMode: vi.fn(), + getCanonicalFps: vi.fn(() => 30), }; } @@ -54,7 +55,22 @@ describe("installRuntimeControlBridge", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); handler(makeControlMessage("seek", { frame: 150, seekMode: "drag" })); - expect(deps.onSeek).toHaveBeenCalledWith(150, "drag"); + expect(deps.onSeek).toHaveBeenCalledWith(5, "drag"); + }); + + it("prefers canonical seconds in protocol v1 seek messages", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler( + makeControlMessage("seek", { + protocolVersion: 1, + capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"], + fps: { numerator: 60, denominator: 1 }, + timeSeconds: 2.5, + frame: 999, + }), + ); + expect(deps.onSeek).toHaveBeenCalledWith(2.5, "commit"); }); it("seek defaults frame to 0 and seekMode to commit", () => { @@ -245,7 +261,32 @@ describe("installRuntimeControlBridge", () => { const postSpy = vi.spyOn(window.parent, "postMessage"); const deps = createMockDeps(); installRuntimeControlBridge(deps); - expect(postSpy).toHaveBeenCalledWith({ source: "hf-preview", type: "ready" }, "*"); + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ + source: "hf-preview", + type: "ready", + protocolVersion: 1, + fps: { numerator: 30, denominator: 1 }, + }), + "*", + ); + postSpy.mockRestore(); + }); + + it("rejects an unknown protocol major with a diagnostic", () => { + const postSpy = vi.spyOn(window.parent, "postMessage"); + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("play", { protocolVersion: 2 })); + + expect(deps.onPlay).not.toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "diagnostic", + code: "runtime.protocol.unsupported_protocol_version", + }), + "*", + ); postSpy.mockRestore(); }); }); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 44597243a5..cb718647ac 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -1,12 +1,18 @@ import { swallow } from "./diagnostics"; import type { HfColorGradingTarget } from "../colorGrading"; import type { RuntimeBridgeControlMessage, RuntimeOutboundMessage } from "./types"; +import { + inspectRuntimeProtocol, + runtimeProtocolFpsToNumber, + runtimeProtocolMetadata, + type RuntimeProtocolV1, +} from "./protocol"; type BridgeDeps = { onPlay: () => void; onPause: () => void; onStopMedia: () => void; - onSeek: (frame: number, seekMode: "drag" | "commit") => void; + onSeek: (timeSeconds: number, seekMode: "drag" | "commit") => void; onTick: () => void; onSetMuted: (muted: boolean) => void; onSetVolume: (volume: number) => void; @@ -22,18 +28,25 @@ type BridgeDeps = { ) => void; onEnablePickMode: () => void; onDisablePickMode: () => void; + getCanonicalFps: () => number; }; +let runtimeProtocolFps = 30; + +export function setRuntimeProtocolFps(fps: number): void { + runtimeProtocolFps = Number.isFinite(fps) && fps > 0 ? fps : 30; +} + export function postRuntimeMessage(payload: RuntimeOutboundMessage): void { try { - window.parent.postMessage(payload, "*"); + window.parent.postMessage({ ...payload, ...runtimeProtocolMetadata(runtimeProtocolFps) }, "*"); } catch (err) { // Cross-frame posting can throw if the parent is gone or origin-isolated. swallow("bridge.postMessage", err); } } -type BridgeControlData = Partial; +type BridgeControlData = Partial; type ControlHandler = (data: BridgeControlData, deps: BridgeDeps) => void; // Per-action dispatchers. Splitting the handler into a lookup table keeps the @@ -43,7 +56,7 @@ const CONTROL_HANDLERS: Record = { play: (_d, deps) => deps.onPlay(), pause: (_d, deps) => deps.onPause(), "stop-media": (_d, deps) => deps.onStopMedia(), - seek: (data, deps) => deps.onSeek(Number(data.frame ?? 0), data.seekMode ?? "commit"), + seek: (data, deps) => deps.onSeek(resolveSeekTimeSeconds(data, deps), data.seekMode ?? "commit"), tick: (_d, deps) => deps.onTick(), "set-muted": (data, deps) => deps.onSetMuted(Boolean(data.muted)), "set-volume": (data, deps) => @@ -64,6 +77,31 @@ const CONTROL_HANDLERS: Record = { "flash-elements": (data) => handleFlashElements(data), }; +function resolveSeekTimeSeconds(data: BridgeControlData, deps: BridgeDeps): number { + const explicitSeconds = Number(data.timeSeconds); + if (Number.isFinite(explicitSeconds)) return Math.max(0, explicitSeconds); + const messageFps = runtimeProtocolFpsToNumber(data.fps); + const fps = messageFps ?? deps.getCanonicalFps(); + return Math.max(0, Number(data.frame ?? 0)) / fps; +} + +function rejectUnsupportedProtocol(data: BridgeControlData): boolean { + const protocol = inspectRuntimeProtocol(data); + if (protocol.status !== "unsupported") return false; + postRuntimeMessage({ + source: "hf-preview", + type: "diagnostic", + code: `runtime.protocol.${protocol.code}`, + details: { + receivedVersion: + typeof protocol.receivedVersion === "string" || typeof protocol.receivedVersion === "number" + ? protocol.receivedVersion + : null, + }, + }); + return true; +} + function handleFlashElements(data: BridgeControlData): void { // Briefly highlight elements — used by the chat-canvas bridge // to show what changed after an agent edit @@ -78,6 +116,7 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv const handler = (event: MessageEvent) => { const data = event.data as BridgeControlData | null; if (!data || data.source !== "hf-parent" || data.type !== "control") return; + if (rejectUnsupportedProtocol(data)) return; const action = data.action; if (typeof action !== "string") return; const fn = CONTROL_HANDLERS[action]; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 67ace5d152..e311c19300 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,5 +1,5 @@ // fallow-ignore-file code-duplication complexity -import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge"; +import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge"; import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; import { injectCompositionCssVariables } from "./getVariables"; import { createCssAdapter } from "./adapters/css"; @@ -93,6 +93,7 @@ export function initSandboxRuntimeModular(): void { applyVariableBindings(document); const exportRenderFps = resolveExportRenderFps(); state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps; + setRuntimeProtocolFps(state.canonicalFps); if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { console.info("[hyperframes] render runtime fps", { canonicalFps: state.canonicalFps, @@ -2172,10 +2173,9 @@ export function initSandboxRuntimeModular(): void { if (el instanceof HTMLMediaElement && !el.paused) el.pause(); } }, - onSeek: (frame, _seekMode) => { - const time = Math.max(0, frame) / state.canonicalFps; - player.seek(time); - emitAnalyticsEvent("composition_seeked", { time }); + onSeek: (timeSeconds, _seekMode) => { + player.seek(timeSeconds); + emitAnalyticsEvent("composition_seeked", { time: timeSeconds }); }, onSetMuted: (muted) => { state.bridgeMuted = muted; @@ -2266,6 +2266,7 @@ export function initSandboxRuntimeModular(): void { }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), + getCanonicalFps: () => state.canonicalFps, }); state.deterministicAdapters = [ diff --git a/packages/core/src/runtime/protocol.test.ts b/packages/core/src/runtime/protocol.test.ts new file mode 100644 index 0000000000..35790b9765 --- /dev/null +++ b/packages/core/src/runtime/protocol.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + inspectRuntimeProtocol, + runtimeProtocolFpsFromNumber, + runtimeProtocolFpsToNumber, + runtimeProtocolMetadata, +} from "./protocol"; + +describe("runtime protocol", () => { + it.each([24, 30, 60, 24_000 / 1_001, 30_000 / 1_001])( + "round-trips %s fps as a rational", + (fps) => { + expect(runtimeProtocolFpsToNumber(runtimeProtocolFpsFromNumber(fps))).toBeCloseTo(fps, 6); + }, + ); + + it("accepts protocol v1 metadata and exposes its fps", () => { + expect(inspectRuntimeProtocol(runtimeProtocolMetadata(60))).toMatchObject({ + status: "supported", + fps: 60, + }); + }); + + it("tolerates additive capabilities within protocol v1", () => { + const metadata = runtimeProtocolMetadata(30); + expect( + inspectRuntimeProtocol({ + ...metadata, + capabilities: [...metadata.capabilities, "future-cap"], + }), + ).toMatchObject({ status: "supported", fps: 30 }); + }); + + it("keeps an explicit legacy fallback", () => { + expect(inspectRuntimeProtocol({ source: "hf-preview" }, 24)).toEqual({ + status: "legacy", + fps: 24, + }); + }); + + it("rejects unknown protocol versions and malformed v1 metadata", () => { + expect(inspectRuntimeProtocol({ protocolVersion: 2 })).toMatchObject({ + status: "unsupported", + code: "unsupported_protocol_version", + }); + expect( + inspectRuntimeProtocol({ protocolVersion: 1, fps: { numerator: 30, denominator: 1 } }), + ).toMatchObject({ status: "unsupported", code: "invalid_protocol_metadata" }); + }); +}); diff --git a/packages/core/src/runtime/protocol.ts b/packages/core/src/runtime/protocol.ts new file mode 100644 index 0000000000..c138b236f1 --- /dev/null +++ b/packages/core/src/runtime/protocol.ts @@ -0,0 +1,92 @@ +export const RUNTIME_PROTOCOL_VERSION = 1 as const; + +export const RUNTIME_PROTOCOL_CAPABILITIES = [ + "seconds-time", + "rational-fps", + "seek-keep-playing", +] as const; + +export type RuntimeProtocolFps = { + numerator: number; + denominator: number; +}; + +export type RuntimeProtocolV1 = { + protocolVersion: typeof RUNTIME_PROTOCOL_VERSION; + capabilities: readonly string[]; + fps: RuntimeProtocolFps; +}; + +export type RuntimeProtocolInspection = + | { status: "legacy"; fps: number } + | { status: "supported"; fps: number; metadata: RuntimeProtocolV1 } + | { + status: "unsupported"; + code: "unsupported_protocol_version" | "invalid_protocol_metadata"; + receivedVersion: unknown; + }; + +function greatestCommonDivisor(a: number, b: number): number { + let left = Math.abs(a); + let right = Math.abs(b); + while (right !== 0) { + const remainder = left % right; + left = right; + right = remainder; + } + return left || 1; +} + +export function runtimeProtocolFpsFromNumber(value: number): RuntimeProtocolFps { + const safe = Number.isFinite(value) && value > 0 ? value : 30; + const denominator = Number.isInteger(safe) ? 1 : 1_000_000; + const numerator = Math.round(safe * denominator); + const divisor = greatestCommonDivisor(numerator, denominator); + return { numerator: numerator / divisor, denominator: denominator / divisor }; +} + +export function runtimeProtocolFpsToNumber(value: unknown): number | null { + if (typeof value !== "object" || value === null) return null; + const fps = value as Partial; + if (!Number.isFinite(fps.numerator) || !Number.isFinite(fps.denominator)) return null; + if ((fps.numerator ?? 0) <= 0 || (fps.denominator ?? 0) <= 0) return null; + return Number(fps.numerator) / Number(fps.denominator); +} + +export function runtimeProtocolMetadata(fps: number): RuntimeProtocolV1 { + return { + protocolVersion: RUNTIME_PROTOCOL_VERSION, + capabilities: RUNTIME_PROTOCOL_CAPABILITIES, + fps: runtimeProtocolFpsFromNumber(fps), + }; +} + +function hasDeclaredCapabilities(value: unknown): boolean { + return Array.isArray(value) && value.every((capability) => typeof capability === "string"); +} + +export function inspectRuntimeProtocol(value: unknown, legacyFps = 30): RuntimeProtocolInspection { + if (typeof value !== "object" || value === null) return { status: "legacy", fps: legacyFps }; + const message = value as Record; + if (message.protocolVersion === undefined) return { status: "legacy", fps: legacyFps }; + if (message.protocolVersion !== RUNTIME_PROTOCOL_VERSION) { + return { + status: "unsupported", + code: "unsupported_protocol_version", + receivedVersion: message.protocolVersion, + }; + } + const fps = runtimeProtocolFpsToNumber(message.fps); + if (fps === null || !hasDeclaredCapabilities(message.capabilities)) { + return { + status: "unsupported", + code: "invalid_protocol_metadata", + receivedVersion: message.protocolVersion, + }; + } + return { + status: "supported", + fps, + metadata: message as RuntimeProtocolV1, + }; +} diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 4f54a1a2f7..f53a80534d 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -27,6 +27,7 @@ export type RuntimeBridgeControlMessage = { type: "control"; action: RuntimeBridgeControlAction; frame?: number; + timeSeconds?: number; muted?: boolean; volume?: number; durationSeconds?: number; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index bf6eb101e7..1e4146234b 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -16,7 +16,8 @@ "files": [ "src/runtime/clipTree.ts", "src/runtime/mediaVolumeEnvelope.ts", - "src/runtime/positionEdits.ts" + "src/runtime/positionEdits.ts", + "src/runtime/protocol.ts" ], "include": ["src/**/*"], "exclude": [ diff --git a/packages/player/package.json b/packages/player/package.json index d80d48bae3..b6c93e2440 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -28,7 +28,7 @@ } }, "scripts": { - "build": "tsup", + "build": "tsup && node scripts/verify-runtime-pin.mjs", "typecheck": "tsc --noEmit && tsc --noEmit -p tests/perf/tsconfig.json", "test": "vitest run", "perf": "bun run tests/perf/index.ts" diff --git a/packages/player/scripts/verify-runtime-pin.mjs b/packages/player/scripts/verify-runtime-pin.mjs new file mode 100644 index 0000000000..d19de8fa96 --- /dev/null +++ b/packages/player/scripts/verify-runtime-pin.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dirname, ".."); +const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const expected = `@hyperframes/core@${version}/dist/hyperframe.runtime.iife.js`; +const bundles = [ + "dist/hyperframes-player.js", + "dist/hyperframes-player.cjs", + "dist/hyperframes-player.global.js", +]; + +for (const bundle of bundles) { + const source = readFileSync(join(root, bundle), "utf8"); + if (!source.includes(expected)) { + throw new Error(`${bundle} does not pin the injected runtime to ${version}`); + } + if (source.includes("@hyperframes/core/dist/hyperframe.runtime.iife.js")) { + throw new Error(`${bundle} still contains an unversioned runtime URL`); + } +} + +console.log(`Verified Player runtime injection is pinned to @hyperframes/core@${version}.`); diff --git a/packages/player/src/composition-probe.test.ts b/packages/player/src/composition-probe.test.ts index fee97a7bfe..2b4a749ac4 100644 --- a/packages/player/src/composition-probe.test.ts +++ b/packages/player/src/composition-probe.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readCompositionSizeFromDocument } from "./composition-probe.js"; +import { readCompositionSizeFromDocument, runtimeCdnUrlForVersion } from "./composition-probe.js"; describe("readCompositionSizeFromDocument", () => { it("reads dimensions from the composition root", () => { @@ -24,3 +24,15 @@ describe("readCompositionSizeFromDocument", () => { expect(readCompositionSizeFromDocument(doc)).toBeNull(); }); }); + +describe("runtimeCdnUrlForVersion", () => { + it("pins the injected core runtime to the Player-compatible version", () => { + expect(runtimeCdnUrlForVersion("1.2.3")).toBe( + "https://cdn.jsdelivr.net/npm/@hyperframes/core@1.2.3/dist/hyperframe.runtime.iife.js", + ); + }); + + it("rejects values that could create an unversioned or malformed URL", () => { + expect(() => runtimeCdnUrlForVersion("latest")).toThrow("Invalid HyperFrames runtime version"); + }); +}); diff --git a/packages/player/src/composition-probe.ts b/packages/player/src/composition-probe.ts index 5ac4c18ddf..b7fdfc0fdd 100644 --- a/packages/player/src/composition-probe.ts +++ b/packages/player/src/composition-probe.ts @@ -19,8 +19,19 @@ import { isRuntimeDurationAdapter, } from "./timeline-adapters.js"; +declare const __HYPERFRAMES_RUNTIME_CDN_URL__: string; + +export function runtimeCdnUrlForVersion(version: string): string { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid HyperFrames runtime version: ${version}`); + } + return `https://cdn.jsdelivr.net/npm/@hyperframes/core@${version}/dist/hyperframe.runtime.iife.js`; +} + const RUNTIME_CDN_URL = - "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; + typeof __HYPERFRAMES_RUNTIME_CDN_URL__ === "string" + ? __HYPERFRAMES_RUNTIME_CDN_URL__ + : runtimeCdnUrlForVersion("0.0.0-dev"); export interface ProbeResult { duration: number; diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 76b9358fab..9b0af53da9 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1008,7 +1008,27 @@ describe("HyperframesPlayer seek() sync path", () => { source: "hf-parent", type: "control", action: "seek", - frame: Math.round(12.5 * 30), + timeSeconds: 12.5, + frame: 375, + protocolVersion: 1, + }), + "*", + ); + }); + + it("carries a frame fallback for accepted legacy cross-origin runtimes", () => { + // An older runtime ignores protocol-v1 metadata and reads only `frame`. + // Omitting it makes that bridge default every seek to frame zero. + const post = vi.fn(); + stubContentWindow({ postMessage: post }); + + player.seek(7.5); + + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ + action: "seek", + timeSeconds: 7.5, + frame: 225, }), "*", ); @@ -1162,7 +1182,15 @@ describe("HyperframesPlayer seek() sync path", () => { player.seek(7); - expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek", frame: 210 }), "*"); + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ + action: "seek", + timeSeconds: 7, + frame: 210, + protocolVersion: 1, + }), + "*", + ); }); it("does not throw when contentWindow access raises (cross-origin embed)", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 8cb607b3d7..dd59a3f166 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -18,6 +18,7 @@ import { createShaderLoader } from "./shader-loader-element.js"; import { ShaderLoaderState } from "./shader-loader-state.js"; import { PLAYER_STYLES } from "./styles.js"; import { type DirectTimelineAdapter } from "./timeline-adapters.js"; +import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol"; // Playback-rate bounds mirror the runtime clamp in // packages/core/src/runtime/init.ts (applyPlaybackRate) and media.ts so the @@ -95,6 +96,7 @@ class HyperframesPlayer extends HTMLElement { private _parentTickRaf: number | null = null; private _media: ParentMediaManager; private _scenes: { id: string; start: number; duration: number }[] = []; + private _runtimeFps = 30; constructor() { super(); @@ -322,7 +324,13 @@ class HyperframesPlayer extends HTMLElement { seek(timeInSeconds: number) { if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) { - this._sendControl("seek", { frame: Math.round(timeInSeconds * 30) }); + this._sendControl("seek", { + timeSeconds: timeInSeconds, + // Legacy runtimes read only `frame`. Protocol-v1 runtimes prefer + // `timeSeconds`, so carrying both keeps cross-origin embeds seekable + // while preserving seconds-first precision between current peers. + frame: Math.round(timeInSeconds * this._runtimeFps), + }); } this._directTimelineClock.stop(); this._stopParentTickClock(); @@ -491,7 +499,13 @@ class HyperframesPlayer extends HTMLElement { private _sendControl(action: string, extra: Record = {}) { try { this.iframe.contentWindow?.postMessage( - { source: "hf-parent", type: "control", action, ...extra }, + { + ...extra, + source: "hf-parent", + type: "control", + action, + ...runtimeProtocolMetadata(this._runtimeFps), + }, "*", ); } catch { @@ -655,6 +669,9 @@ class HyperframesPlayer extends HTMLElement { getIframeDoc: () => this.iframe.contentDocument, onRuntimeReady: () => this._replayBridgeState(), onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration), + setRuntimeFps: (fps) => { + this._runtimeFps = fps; + }, shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(), setScenes: (scenes) => { this._scenes = scenes; diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index 6a1beba13a..569bf9f8b3 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { handleRuntimeMessage, type MessageHandlerCallbacks } from "./runtime-message-handler.js"; import type { ParentMediaManager } from "./parent-media.js"; import type { ShaderLoaderState } from "./shader-loader-state.js"; +import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol"; // Only the stage-size branch is exercised here; the rest of the callback // surface is satisfied with inert spies so the handler's type contract @@ -13,6 +14,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({ dispatchEvent: vi.fn(), onRuntimeReady: vi.fn(), onRuntimeTimelineReady: vi.fn(), + setRuntimeFps: vi.fn(), seek: vi.fn(), play: vi.fn(), getLoop: vi.fn(() => false), @@ -104,10 +106,20 @@ describe("handleRuntimeMessage media autoplay fallback", () => { }); describe("handleRuntimeMessage timeline ready", () => { - const timelineEvent = (durationInFrames: unknown, source: object): MessageEvent => + const timelineEvent = ( + durationInFrames: unknown, + source: object, + metadata: Record = {}, + ): MessageEvent => ({ source, - data: { source: "hf-preview", type: "timeline", durationInFrames, scenes: [] }, + data: { + source: "hf-preview", + type: "timeline", + durationInFrames, + scenes: [], + ...metadata, + }, }) as unknown as MessageEvent; it("reports a finite positive timeline duration in seconds", () => { @@ -119,6 +131,45 @@ describe("handleRuntimeMessage timeline ready", () => { expect(callbacks.onRuntimeTimelineReady).toHaveBeenCalledWith(4); }); + it.each([ + [24, 48, 2], + [60, 120, 2], + [24_000 / 1_001, 24, 1.001], + ])("uses explicit %s fps metadata", (fps, durationInFrames, expectedSeconds) => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + + handleRuntimeMessage( + timelineEvent(durationInFrames, frameWindow, runtimeProtocolMetadata(fps)), + frameWindow, + callbacks, + ); + + expect(callbacks.setRuntimeFps).toHaveBeenCalledWith(expect.closeTo(fps, 6)); + expect(callbacks.onRuntimeTimelineReady).toHaveBeenCalledWith( + expect.closeTo(expectedSeconds, 6), + ); + }); + + it("rejects unknown protocol majors with a public diagnostic event", () => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + + handleRuntimeMessage( + timelineEvent(120, frameWindow, { protocolVersion: 2 }), + frameWindow, + callbacks, + ); + + expect(callbacks.onRuntimeTimelineReady).not.toHaveBeenCalled(); + const event = vi.mocked(callbacks.dispatchEvent).mock.calls[0]?.[0] as CustomEvent; + expect(event.type).toBe("runtimeprotocolerror"); + expect(event.detail).toMatchObject({ + code: "unsupported_protocol_version", + receivedVersion: 2, + }); + }); + it("does not report invalid timeline durations as ready", () => { const frameWindow = {} as Window; const callbacks = makeCallbacks(); diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index a28a30d58e..d927e53e73 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -12,8 +12,7 @@ import { } from "./playback-state.js"; import type { ShaderLoaderState } from "./shader-loader-state.js"; import type { ShaderTransitionState } from "./shader-options.js"; - -const FPS = 30; +import { inspectRuntimeProtocol } from "@hyperframes/core/runtime/protocol"; type SceneRecord = { id: string; start: number; duration: number }; @@ -45,6 +44,7 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks { * player uses this as the cross-origin readiness signal because the * same-origin composition probe cannot inspect CDN iframes. */ onRuntimeTimelineReady: (duration: number) => void; + setRuntimeFps?: (fps: number) => void; /** Called with the scene list whenever a "timeline" message is received. */ setScenes: (scenes: SceneRecord[]) => void; /** Return false to ignore the iframe runtime's audible-media autoplay fallback. @@ -62,6 +62,16 @@ export function handleRuntimeMessage( if (event.source !== frameWindow) return; const data = event.data as Record | undefined; if (!data || data["source"] !== "hf-preview") return; + const protocol = inspectRuntimeProtocol(data); + if (protocol.status === "unsupported") { + callbacks.dispatchEvent( + new CustomEvent("runtimeprotocolerror", { + detail: { code: protocol.code, receivedVersion: protocol.receivedVersion }, + }), + ); + return; + } + callbacks.setRuntimeFps?.(protocol.fps); if (data["type"] === "shader-transition-state") { const state: ShaderTransitionState = @@ -86,7 +96,7 @@ export function handleRuntimeMessage( callbacks.setPlaybackState( applyRuntimeStateMessage( { frame: (data["frame"] as number) ?? 0, isPlaying: !!data["isPlaying"] }, - FPS, + protocol.fps, callbacks.getPlaybackState(), callbacks, ), @@ -112,7 +122,7 @@ export function handleRuntimeMessage( if (data["type"] === "timeline" && (data["durationInFrames"] as number) > 0) { if (Number.isFinite(data["durationInFrames"])) { const pb = callbacks.getPlaybackState(); - const duration = (data["durationInFrames"] as number) / FPS; + const duration = (data["durationInFrames"] as number) / protocol.fps; callbacks.setPlaybackState({ ...pb, duration }); callbacks.updateControlsTime(pb.currentTime, duration); callbacks.onRuntimeTimelineReady(duration); diff --git a/packages/player/tsup.config.ts b/packages/player/tsup.config.ts index 87ae66a4aa..8b6f443556 100644 --- a/packages/player/tsup.config.ts +++ b/packages/player/tsup.config.ts @@ -1,4 +1,8 @@ import { defineConfig } from "tsup"; +import { readFileSync } from "node:fs"; + +const packageVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) + .version as string; export default defineConfig({ entry: ["src/hyperframes-player.ts", "src/slideshow/hyperframes-slideshow.ts"], @@ -9,4 +13,9 @@ export default defineConfig({ clean: true, minify: true, sourcemap: true, + define: { + __HYPERFRAMES_RUNTIME_CDN_URL__: JSON.stringify( + `https://cdn.jsdelivr.net/npm/@hyperframes/core@${packageVersion}/dist/hyperframe.runtime.iife.js`, + ), + }, }); diff --git a/packages/player/vitest.config.ts b/packages/player/vitest.config.ts index 5f8f7a426a..98c4399003 100644 --- a/packages/player/vitest.config.ts +++ b/packages/player/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ resolve: { alias: { "@hyperframes/core/slideshow": resolve(coreRoot, "slideshow/index.ts"), + "@hyperframes/core/runtime/protocol": resolve(coreRoot, "runtime/protocol.ts"), }, }, test: { diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index 14a6cb1808..72553582e1 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -24,6 +24,10 @@ import type { DomEditSelection } from "./domEditing"; import { ColorGradingControls } from "./propertyPanelColorGradingControls"; import { stripQueryAndHash } from "./propertyPanelHelpers"; import { Section } from "./propertyPanelPrimitives"; +import { + acceptStudioRuntimeMessage, + postRuntimeControlMessage, +} from "../../player/lib/runtimeProtocol"; const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, ""); const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const; @@ -393,35 +397,23 @@ export function ColorGradingSection({ const postColorGrading = useCallback( (nextGrading: NormalizedHfColorGrading) => { - previewIframeRef?.current?.contentWindow?.postMessage( - { - source: "hf-parent", - type: "control", - action: "set-color-grading", - target, - grading: toBridgeColorGrading(nextGrading), - }, - "*", - ); + postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", { + target, + grading: toBridgeColorGrading(nextGrading), + }); }, [previewIframeRef, target], ); const postCompare = useCallback( (enabled: boolean) => { - previewIframeRef?.current?.contentWindow?.postMessage( + postRuntimeControlMessage( + previewIframeRef?.current?.contentWindow, + "set-color-grading-compare", { - source: "hf-parent", - type: "control", - action: "set-color-grading-compare", target, - compare: { - enabled, - position: 1, - lineWidth: 0, - }, + compare: { enabled, position: 1, lineWidth: 0 }, }, - "*", ); }, [previewIframeRef, target], @@ -440,7 +432,9 @@ export function ColorGradingSection({ const onMessage = (event: MessageEvent) => { if (event.source !== iframe.contentWindow) return; const data = event.data as { source?: unknown; type?: unknown } | null; - if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay(); + if (data?.source !== "hf-preview" || data.type !== "ready") return; + if (!acceptStudioRuntimeMessage(data)) return; + refreshAndReplay(); }; iframe.addEventListener("load", refreshAndReplay); window.addEventListener("message", onMessage); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index ebd9f5e949..7609d8a9f5 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -9,6 +9,8 @@ import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory"; import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; import { extendRootDurationInSource } from "../utils/rootDuration"; +import { postRuntimeControlMessage } from "../player/lib/runtimeProtocol"; + export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes"; function isHTMLElement(element: Element | null): element is HTMLElement { if (!element) return false; @@ -153,15 +155,9 @@ function postRootDurationToPreview( ): void { const duration = Number(durationSeconds); if (!Number.isFinite(duration) || duration <= 0) return; - iframe?.contentWindow?.postMessage( - { - source: "hf-parent", - type: "control", - action: "set-root-duration", - durationSeconds: duration, - }, - "*", - ); + postRuntimeControlMessage(iframe?.contentWindow, "set-root-duration", { + durationSeconds: duration, + }); } // fallow-ignore-next-line complexity function resolveResizePlaybackStart( diff --git a/packages/studio/src/hooks/useCaptionDetection.ts b/packages/studio/src/hooks/useCaptionDetection.ts index fcf83e5501..c16a18a861 100644 --- a/packages/studio/src/hooks/useCaptionDetection.ts +++ b/packages/studio/src/hooks/useCaptionDetection.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { useCaptionStore } from "../captions/store"; +import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol"; import { useCaptionSync } from "../captions/hooks/useCaptionSync"; import { parseCaptionComposition } from "../captions/parser"; @@ -111,6 +112,7 @@ export function useCaptionDetection({ const handleMessage = (e: MessageEvent) => { const data = e.data; if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) { + if (!acceptStudioRuntimeMessage(data)) return; tryActivateCaptions(); } }; diff --git a/packages/studio/src/hooks/useCompositionDimensions.ts b/packages/studio/src/hooks/useCompositionDimensions.ts index 16c63d51df..49a5371496 100644 --- a/packages/studio/src/hooks/useCompositionDimensions.ts +++ b/packages/studio/src/hooks/useCompositionDimensions.ts @@ -1,6 +1,33 @@ import { useState } from "react"; import { useMountEffect } from "./useMountEffect"; import type { CompositionDimensions } from "../components/renders/RenderQueue"; +import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol"; + +function readCompositionSizeMessage(data: unknown): CompositionDimensions | null { + if (!isStageSizeMessage(data)) return null; + const message = data; + if (!acceptStudioRuntimeMessage(message)) return null; + return readPositiveDimensions(message.width, message.height); +} + +function isStageSizeMessage(value: unknown): value is Record { + if (typeof value !== "object") return false; + if (value === null) return false; + const message = value as Record; + return message.source === "hf-preview" && message.type === "stage-size"; +} + +function readPositiveNumber(value: unknown): number | null { + if (typeof value !== "number") return null; + return Number.isFinite(value) && value > 0 ? value : null; +} + +function readPositiveDimensions(width: unknown, height: unknown): CompositionDimensions | null { + const parsedWidth = readPositiveNumber(width); + const parsedHeight = readPositiveNumber(height); + if (parsedWidth === null || parsedHeight === null) return null; + return { width: parsedWidth, height: parsedHeight }; +} export function useCompositionDimensions() { const [compositionDimensions, setCompositionDimensions] = useState( @@ -9,12 +36,12 @@ export function useCompositionDimensions() { useMountEffect(() => { const handleMessage = (e: MessageEvent) => { - const data = e.data; - if (data?.source !== "hf-preview" || data?.type !== "stage-size") return; - const { width, height } = data as { width: number; height: number }; - if (!(width > 0) || !(height > 0)) return; + const dimensions = readCompositionSizeMessage(e.data); + if (!dimensions) return; setCompositionDimensions((prev) => - prev && prev.width === width && prev.height === height ? prev : { width, height }, + prev && prev.width === dimensions.width && prev.height === dimensions.height + ? prev + : dimensions, ); }; window.addEventListener("message", handleMessage); diff --git a/packages/studio/src/hooks/useElementPicker.ts b/packages/studio/src/hooks/useElementPicker.ts index 6ba1dfb614..b04848377d 100644 --- a/packages/studio/src/hooks/useElementPicker.ts +++ b/packages/studio/src/hooks/useElementPicker.ts @@ -1,6 +1,10 @@ import { useState, useCallback, useRef } from "react"; import { useMountEffect } from "./useMountEffect"; import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher"; +import { + acceptStudioRuntimeMessage, + postRuntimeControlMessage, +} from "../player/lib/runtimeProtocol"; export interface PickedElement { id: string | null; @@ -65,10 +69,7 @@ export function useElementPicker( const enablePick = useCallback(() => { try { - getActiveIframe()?.contentWindow?.postMessage( - { source: "hf-parent", type: "control", action: "enable-pick-mode" }, - "*", - ); + postRuntimeControlMessage(getActiveIframe()?.contentWindow, "enable-pick-mode"); setIsPickMode(true); } catch { /* cross-origin */ @@ -77,10 +78,7 @@ export function useElementPicker( const disablePick = useCallback(() => { try { - getActiveIframe()?.contentWindow?.postMessage( - { source: "hf-parent", type: "control", action: "disable-pick-mode" }, - "*", - ); + postRuntimeControlMessage(getActiveIframe()?.contentWindow, "disable-pick-mode"); } catch { /* cross-origin */ } @@ -96,6 +94,7 @@ export function useElementPicker( const handleMessage = (e: MessageEvent) => { const data = e.data; if (data?.source !== "hf-preview") return; + if (!acceptStudioRuntimeMessage(data)) return; // Accept events from either the primary iframe or the active override const activeIframe = getActiveIframe(); if (!activeIframe) return; diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index cb4466f88a..3cc74fbfd7 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -288,12 +288,13 @@ describe("useTimelineEditing timeline z-index reorder", () => { expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); expect(reloadPreview).not.toHaveBeenCalled(); expect(postMessageSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ source: "hf-parent", type: "control", action: "set-root-duration", durationSeconds: 5, - }, + protocolVersion: 1, + }), "*", ); @@ -354,12 +355,13 @@ describe("useTimelineEditing timeline z-index reorder", () => { expect(forceReloadSdkSession).toHaveBeenCalledTimes(1); expect(reloadPreview).not.toHaveBeenCalled(); expect(postMessageSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ source: "hf-parent", type: "control", action: "set-root-duration", durationSeconds: 5, - }, + protocolVersion: 1, + }), "*", ); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 3036380532..45db5c9fcc 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => { unmountWithAct(root); unsubscribe(); }); + + it("does not settle from an unsupported runtime protocol message", () => { + const { api, root } = renderTimelinePlayerHarness(); + const iframe = document.createElement("iframe"); + const iframeWindow = { + postMessage: vi.fn(), + scrollTo: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as Record; + Object.defineProperty(iframe, "contentWindow", { + value: iframeWindow, + configurable: true, + }); + Object.defineProperty(iframe, "contentDocument", { + value: document.implementation.createHTMLDocument("preview"), + configurable: true, + }); + + act(() => { + api.iframeRef.current = iframe; + api.onIframeLoad(); + }); + expect(usePlayerStore.getState().timelineReady).toBe(false); + + iframeWindow.__player = { + play: vi.fn(), + pause: vi.fn(), + seek: vi.fn(), + getTime: () => 0, + getDuration: () => 30, + isPlaying: () => false, + }; + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + source: iframeWindow as unknown as Window, + data: { source: "hf-preview", type: "state", protocolVersion: 999 }, + }), + ); + }); + expect(usePlayerStore.getState().timelineReady).toBe(false); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + source: iframeWindow as unknown as Window, + data: { source: "hf-preview", type: "state" }, + }), + ); + }); + expect(usePlayerStore.getState().timelineReady).toBe(true); + + unmountWithAct(root); + }); }); describe("useTimelinePlayer audio controls (#835)", () => { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 4ca1b452c3..44bb17717d 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -46,6 +46,7 @@ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub"; import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe"; import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; +import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol"; /** * Whether the derived elements differ from the current ones in any field that @@ -492,6 +493,9 @@ export function useTimelinePlayer() { if (e.source && ourIframe && e.source !== ourIframe.contentWindow) { return; } + if (data?.source === "hf-preview") { + if (!acceptStudioRuntimeMessage(data)) return; + } if (data?.source === "hf-preview" && data?.type === "state") { try { if (usePlayerStore.getState().elements.length === 0) { diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 4992efb5c0..f7c222be70 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -26,6 +26,7 @@ import { autoHealMissingCompositionIds, buildMissingCompositionElements, } from "../lib/timelineIframeHelpers"; +import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol"; interface UseTimelineSyncCallbacksParams { iframeRef: React.RefObject; @@ -132,6 +133,9 @@ export function useTimelineSyncCallbacks({ clips: ClipManifestClip[]; durationInFrames: number; scenes?: Array<{ id: string; label: string; start: number; duration: number }>; + protocolVersion?: unknown; + capabilities?: unknown; + fps?: unknown; }) => { if (!data.clips || data.clips.length === 0) { return; @@ -222,7 +226,7 @@ export function useTimelineSyncCallbacks({ hostEl, }); }); - const rawDuration = data.durationInFrames / 30; + const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data); // Clamp non-finite or absurdly large durations — the runtime can emit // Infinity when it detects a loop-inflated GSAP timeline without an // explicit data-duration on the root composition. Floor the manifest total @@ -418,6 +422,10 @@ export function useTimelineSyncCallbacks({ if (e.source && iframe && e.source !== iframe.contentWindow) return; const data = e.data; if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) { + // The main message handler owns protocol-error diagnostics. This readiness-only + // listener mirrors its acceptance gate without dispatching a duplicate event: + // an unsupported runtime must not make the iframe appear successfully settled. + if (inspectStudioRuntimeMessage(data).status === "unsupported") return; trySettle(); } }; diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index 72e50a2bcd..db2bc1f423 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -11,7 +11,9 @@ export { resolveIframe } from "./lib/timelineDOM"; // Store export { usePlayerStore, liveTime } from "./store/playerStore"; -export type { SelectElementOptions, TimelineElement } from "./store/playerStore"; +// Public library surface; external consumers are invisible to the workspace analyzer. +// fallow-ignore-next-line unused-exports +export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore"; // Utils export { formatTime } from "./lib/time"; diff --git a/packages/studio/src/player/lib/runtimeProtocol.test.ts b/packages/studio/src/player/lib/runtimeProtocol.test.ts new file mode 100644 index 0000000000..3747266af0 --- /dev/null +++ b/packages/studio/src/player/lib/runtimeProtocol.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { + acceptStudioRuntimeMessage, + acceptedRuntimeMessageFps, + createRuntimeControlMessage, + inspectStudioRuntimeMessage, + postRuntimeControlMessage, +} from "./runtimeProtocol"; + +describe("Studio runtime protocol", () => { + it("versions every control message and declares rational fps", () => { + expect(createRuntimeControlMessage("seek", { timeSeconds: 1.25 }, 60)).toEqual({ + source: "hf-parent", + type: "control", + action: "seek", + protocolVersion: 1, + capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"], + fps: { numerator: 60, denominator: 1 }, + timeSeconds: 1.25, + }); + }); + + it("posts the typed message to the target window", () => { + const target = { postMessage: vi.fn() }; + postRuntimeControlMessage(target as unknown as Window, "pause"); + expect(target.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ action: "pause", protocolVersion: 1 }), + "*", + ); + }); + + it("preserves legacy 30fps messages and rejects unknown majors", () => { + expect(inspectStudioRuntimeMessage({ source: "hf-preview" })).toEqual({ + status: "legacy", + fps: 30, + }); + expect(inspectStudioRuntimeMessage({ protocolVersion: 2 })).toMatchObject({ + status: "unsupported", + code: "unsupported_protocol_version", + }); + }); + + it("reads explicit fps for accepted timeline messages", () => { + const message = createRuntimeControlMessage("pause", {}, 60); + expect(acceptedRuntimeMessageFps(message)).toBe(60); + expect(acceptStudioRuntimeMessage(message)).toMatchObject({ status: "supported", fps: 60 }); + }); +}); diff --git a/packages/studio/src/player/lib/runtimeProtocol.ts b/packages/studio/src/player/lib/runtimeProtocol.ts new file mode 100644 index 0000000000..7aeccb582a --- /dev/null +++ b/packages/studio/src/player/lib/runtimeProtocol.ts @@ -0,0 +1,65 @@ +import { + inspectRuntimeProtocol, + runtimeProtocolMetadata, + type RuntimeProtocolInspection, +} from "@hyperframes/core/runtime/protocol"; + +export type RuntimeControlMessage = { + source: "hf-parent"; + type: "control"; + action: string; +} & ReturnType & + Record; + +export function createRuntimeControlMessage( + action: string, + payload: Record = {}, + fps = 30, +): RuntimeControlMessage { + return { + ...payload, + source: "hf-parent", + type: "control", + action, + ...runtimeProtocolMetadata(fps), + }; +} + +export function postRuntimeControlMessage( + target: Pick | null | undefined, + action: string, + payload: Record = {}, + fps = 30, +): void { + target?.postMessage(createRuntimeControlMessage(action, payload, fps), "*"); +} + +export function inspectStudioRuntimeMessage(value: unknown): RuntimeProtocolInspection { + return inspectRuntimeProtocol(value, 30); +} + +function dispatchRuntimeProtocolError(inspection: RuntimeProtocolInspection): void { + if (inspection.status !== "unsupported") return; + window.dispatchEvent( + new CustomEvent("runtimeprotocolerror", { + detail: { + code: inspection.code, + receivedVersion: inspection.receivedVersion, + }, + }), + ); +} + +export function acceptStudioRuntimeMessage( + value: unknown, +): Exclude | null { + const inspection = inspectStudioRuntimeMessage(value); + if (inspection.status !== "unsupported") return inspection; + dispatchRuntimeProtocolError(inspection); + return null; +} + +export function acceptedRuntimeMessageFps(value: unknown): number { + const inspection = inspectStudioRuntimeMessage(value); + return inspection.status === "supported" ? inspection.fps : 30; +} diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 6fa45bf2c4..f00700f458 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -20,6 +20,7 @@ import { buildTimelineElementIdentity, readTimelineElementZIndex, } from "./timelineElementHelpers"; +import { postRuntimeControlMessage } from "./runtimeProtocol"; // --------------------------------------------------------------------------- // Viewport / DOM normalisation @@ -103,10 +104,7 @@ function postPreviewControl( action: string, payload: Record, ): void { - iframe.contentWindow?.postMessage( - { source: "hf-parent", type: "control", action, ...payload }, - "*", - ); + postRuntimeControlMessage(iframe.contentWindow, action, payload); } export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {