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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
45 changes: 43 additions & 2 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function createMockDeps() {
onSetRootDuration: vi.fn(),
onEnablePickMode: vi.fn(),
onDisablePickMode: vi.fn(),
getCanonicalFps: vi.fn(() => 30),
};
}

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
47 changes: 43 additions & 4 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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) =>
Expand All @@ -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
Expand All @@ -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];
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2266,6 +2266,7 @@ export function initSandboxRuntimeModular(): void {
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
getCanonicalFps: () => state.canonicalFps,
});

state.deterministicAdapters = [
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/runtime/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 92 additions & 0 deletions packages/core/src/runtime/protocol.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type RuntimeBridgeControlMessage = {
type: "control";
action: RuntimeBridgeControlAction;
frame?: number;
timeSeconds?: number;
muted?: boolean;
volume?: number;
durationSeconds?: number;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading
Loading