fix(player): version runtime protocol

This commit is contained in:
James
2026-07-13 13:28:11 -04:00
parent 95fa14b2c2
commit dcefdd98ca
32 changed files with 683 additions and 78 deletions
+6
View File
@@ -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.
{
+10
View File
@@ -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"
+43 -2
View File
@@ -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();
});
});
+43 -4
View File
@@ -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<RuntimeBridgeControlMessage>;
type BridgeControlData = Partial<RuntimeBridgeControlMessage & RuntimeProtocolV1>;
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<string, ControlHandler> = {
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<string, ControlHandler> = {
"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];
+6 -5
View File
@@ -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 = [
@@ -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" });
});
});
+92
View File
@@ -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<RuntimeProtocolFps>;
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<string, unknown>;
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,
};
}
+1
View File
@@ -27,6 +27,7 @@ export type RuntimeBridgeControlMessage = {
type: "control";
action: RuntimeBridgeControlAction;
frame?: number;
timeSeconds?: number;
muted?: boolean;
volume?: number;
durationSeconds?: number;
+2 -1
View File
@@ -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": [
+1 -1
View File
@@ -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"
@@ -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}.`);
+13 -1
View File
@@ -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");
});
});
+12 -1
View File
@@ -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;
+30 -2
View File
@@ -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)", () => {
+19 -2
View File
@@ -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<string, unknown> = {}) {
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;
@@ -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<string, unknown> = {},
): 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();
+14 -4
View File
@@ -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<string, unknown> | 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);
+9
View File
@@ -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`,
),
},
});
+1
View File
@@ -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: {
@@ -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);
@@ -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(
@@ -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();
}
};
@@ -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<string, unknown> {
if (typeof value !== "object") return false;
if (value === null) return false;
const message = value as Record<string, unknown>;
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<CompositionDimensions | null>(
@@ -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);
@@ -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;
@@ -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,
}),
"*",
);
@@ -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<string, unknown>;
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)", () => {
@@ -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) {
@@ -26,6 +26,7 @@ import {
autoHealMissingCompositionIds,
buildMissingCompositionElements,
} from "../lib/timelineIframeHelpers";
import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
interface UseTimelineSyncCallbacksParams {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -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();
}
};
+3 -1
View File
@@ -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";
@@ -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 });
});
});
@@ -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<typeof runtimeProtocolMetadata> &
Record<string, unknown>;
export function createRuntimeControlMessage(
action: string,
payload: Record<string, unknown> = {},
fps = 30,
): RuntimeControlMessage {
return {
...payload,
source: "hf-parent",
type: "control",
action,
...runtimeProtocolMetadata(fps),
};
}
export function postRuntimeControlMessage(
target: Pick<Window, "postMessage"> | null | undefined,
action: string,
payload: Record<string, unknown> = {},
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<RuntimeProtocolInspection, { status: "unsupported" }> | 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;
}
@@ -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<string, unknown>,
): void {
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action, ...payload },
"*",
);
postRuntimeControlMessage(iframe.contentWindow, action, payload);
}
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {