refactor(core): unify composition contract (#2157)

* refactor(core): unify composition contract

* fix(parsers): parse start expressions linearly
This commit is contained in:
James Russo
2026-07-16 02:44:22 -04:00
committed by GitHub
parent 9ed255c0ef
commit 21cb722ebd
36 changed files with 1148 additions and 441 deletions
+1
View File
@@ -0,0 +1 @@
export * from "@hyperframes/parsers/composition-contract";
@@ -54,8 +54,10 @@ describe("generateHyperframesHtml", () => {
expect(html).toContain('id="my-text"');
expect(html).toContain('data-start="2"');
expect(html).toContain('data-end="5"');
expect(html).toContain('data-layer="1"');
expect(html).toContain('data-duration="3"');
expect(html).toContain('data-track-index="1"');
expect(html).not.toContain('data-end="');
expect(html).not.toContain('data-layer="');
});
it("includes GSAP CDN script tag when includeScripts is true", () => {
+4 -3
View File
@@ -8,6 +8,7 @@ import {
import type { GsapAnimation } from "@hyperframes/parsers";
import { serializeGsapAnimations, keyframesToGsapAnimations } from "@hyperframes/parsers";
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
import { COMPOSITION_ATTRIBUTES } from "../compositionContract.js";
const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
@@ -446,9 +447,9 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]):
const baseAttrs = [
`id="${element.id}"`,
`data-hf-id="${element.id}"`,
`data-start="${element.startTime}"`,
`data-end="${element.startTime + element.duration}"`,
`data-layer="${element.zIndex}"`,
`${COMPOSITION_ATTRIBUTES.start}="${element.startTime}"`,
`${COMPOSITION_ATTRIBUTES.duration}="${element.duration}"`,
`${COMPOSITION_ATTRIBUTES.trackIndex}="${element.zIndex}"`,
`data-name="${element.name}"`,
];
+14
View File
@@ -241,6 +241,20 @@ export {
parseNumeric,
type ReferenceExpression,
} from "./runtime/startExpression.js";
export {
COMPOSITION_CONTRACT_VERSION,
COMPOSITION_ATTRIBUTES,
CANONICAL_AUTHORED_TIMING_ATTRIBUTES,
DERIVED_TIMING_ATTRIBUTES,
LEGACY_TIMING_ATTRIBUTES,
readClipTiming,
writeClipTiming,
ClipTimingWriteError,
type ClipTiming,
type ClipTimingDiagnostic,
type ClipTimingDiagnosticCode,
type ClipTimingUpdate,
} from "./compositionContract.js";
// Variable validation (CLI / tooling-side)
export {
+1
View File
@@ -4,6 +4,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [
"seconds-time",
"rational-fps",
"seek-keep-playing",
"composition-manifest-v1",
] as const;
export type RuntimeProtocolFps = {
+5 -33
View File
@@ -12,36 +12,8 @@
* - `"intro - 0.5"` -> 0.5s before `intro` ends (overlap)
*/
export type ReferenceExpression =
| { kind: "absolute"; value: number }
| { kind: "reference"; refId: string; offset: number };
/** Parse a value to a finite number, or `null` if it isn't one. */
export function parseNumeric(value: string | null | undefined): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Parse a raw `data-start` value into an absolute time or a clip reference.
* Returns `null` when the value is empty or not a recognized expression.
*/
export function parseStartExpression(raw: string | null | undefined): ReferenceExpression | null {
const normalized = (raw ?? "").trim();
if (!normalized) return null;
const absolute = parseNumeric(normalized);
if (absolute != null) {
return { kind: "absolute", value: absolute };
}
const referenceMatch = normalized.match(/^([A-Za-z0-9_.:-]+)(?:\s*([+-])\s*([0-9]*\.?[0-9]+))?$/);
if (!referenceMatch) return null;
const refId = (referenceMatch[1] ?? "").trim();
if (!refId) return null;
const sign = referenceMatch[2] ?? "+";
const offsetRaw = referenceMatch[3] ?? "0";
const parsedOffset = Number.parseFloat(offsetRaw);
const offsetMagnitude = Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0;
const offset = sign === "-" ? -offsetMagnitude : offsetMagnitude;
return { kind: "reference", refId, offset };
}
export {
parseNumeric,
parseStartExpression,
type ReferenceExpression,
} from "@hyperframes/parsers/composition-contract";
@@ -18,6 +18,12 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.durationInFrames).toBeGreaterThanOrEqual(1);
expect(result.compositionWidth).toBe(1920);
expect(result.compositionHeight).toBe(1080);
expect(result).toMatchObject({
protocolVersion: 1,
compositionContractVersion: 1,
durationSeconds: 1,
fps: { numerator: 30, denominator: 1 },
});
});
// Regression: id-less timed elements (root index.html children carry
+5
View File
@@ -10,6 +10,8 @@ import { readElementPlaybackRate } from "./media";
import { resolveCssStackingContextId } from "./stackingContext";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { isSceneLikeCompositionId } from "../slideshow/index.js";
import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js";
import { runtimeProtocolMetadata } from "./protocol.js";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -630,8 +632,11 @@ export function collectRuntimeTimelinePayload(params: {
? Number.POSITIVE_INFINITY
: Math.max(1, Math.ceil(safeDuration * Math.max(1, params.canonicalFps)));
return {
...runtimeProtocolMetadata(params.canonicalFps),
source: "hf-preview",
type: "timeline",
compositionContractVersion: COMPOSITION_CONTRACT_VERSION,
durationSeconds: shouldEmitNonDeterministicInf ? Number.POSITIVE_INFINITY : safeDuration,
durationInFrames,
clips,
scenes,
+4 -1
View File
@@ -10,6 +10,7 @@ export type RuntimeJson =
import type { HyperframeControlAction } from "../inline-scripts/runtimeContract.js";
import type { HyperframePickerElementInfo } from "../inline-scripts/pickerApi.js";
import type { RuntimeProtocolV1 } from "./protocol.js";
export type RuntimeBridgeControlAction =
| HyperframeControlAction
@@ -79,9 +80,11 @@ export type RuntimeTimelineScene = {
avatarName: string | null;
};
export type RuntimeTimelineMessage = {
export type RuntimeTimelineMessage = RuntimeProtocolV1 & {
source: "hf-preview";
type: "timeline";
compositionContractVersion: 1;
durationSeconds: number;
durationInFrames: number;
clips: RuntimeTimelineClip[];
scenes: RuntimeTimelineScene[];