mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(player): version runtime protocol
This commit is contained in:
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export type RuntimeBridgeControlMessage = {
|
||||
type: "control";
|
||||
action: RuntimeBridgeControlAction;
|
||||
frame?: number;
|
||||
timeSeconds?: number;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
durationSeconds?: number;
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user