Skip to content
Open
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
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
"import": "./src/utils/htmlAttrSafety.ts",
"types": "./src/utils/htmlAttrSafety.ts"
},
"./composition-contract": {
"bun": "./src/compositionContract.ts",
"node": "./dist/compositionContract.js",
"import": "./src/compositionContract.ts",
"types": "./src/compositionContract.ts"
},
"./editing": {
"bun": "./src/editing/affordances.ts",
"node": "./dist/editing/affordances.js",
Expand Down Expand Up @@ -264,6 +270,10 @@
"import": "./dist/utils/htmlAttrSafety.js",
"types": "./dist/utils/htmlAttrSafety.d.ts"
},
"./composition-contract": {
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compositionContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "@hyperframes/parsers/composition-contract";
6 changes: 4 additions & 2 deletions packages/core/src/generators/hyperframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/generators/hyperframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand Down Expand Up @@ -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}"`,
];

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [
"seconds-time",
"rational-fps",
"seek-keep-playing",
"composition-manifest-v1",
] as const;

export type RuntimeProtocolFps = {
Expand Down
38 changes: 5 additions & 33 deletions packages/core/src/runtime/startExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 6 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
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";
Expand Down Expand Up @@ -682,8 +684,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,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[];
Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

describe("composition rules", () => {
describe("canonical timing contract", () => {
it("rejects deprecated attributes even when canonical attributes are also present", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div data-composition-id="main" data-start="0" data-duration="5">
<div class="clip" data-start="1" data-duration="2" data-end="9" data-track-index="1" data-layer="4"></div>
</div>
</body></html>`);

expect(result.findings.map(({ code }) => code)).toEqual(
expect.arrayContaining(["deprecated_data_end", "deprecated_data_layer"]),
);
});

it("uses canonical timing when deprecated attributes conflict", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div data-composition-id="main" data-start="0" data-duration="5">
<div id="a" class="clip" data-start="0" data-duration="2" data-end="9" data-track-index="1"></div>
<div id="b" class="clip" data-start="2" data-duration="2" data-track-index="1"></div>
</div>
</body></html>`);

expect(
result.findings.find(({ code }) => code === "overlapping_clips_same_track"),
).toBeUndefined();
});
});

describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
Expand Down
25 changes: 14 additions & 11 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";

// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
Expand All @@ -23,6 +24,10 @@ const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"])
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
const OVERLAP_EPSILON_SECONDS = 1e-6;

function readTagTiming(rawTag: string) {
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
}

function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;

Expand Down Expand Up @@ -256,7 +261,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;

const track = readAttr(tag.raw, "data-track-index");
const track = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}
Expand Down Expand Up @@ -313,7 +318,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
const timing = readTagTiming(tag.raw);
if (timing.diagnostics.some(({ code }) => code === "deprecated-layer")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_layer",
Expand All @@ -324,7 +330,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
snippet: truncateSnippet(tag.raw),
});
}
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
if (timing.diagnostics.some(({ code }) => code === "deprecated-end")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_end",
Expand Down Expand Up @@ -435,17 +441,14 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const trackMap = new Map<string, ClipInfo[]>();

for (const tag of tags) {
const startStr = readAttr(tag.raw, "data-start");
const durationStr = readAttr(tag.raw, "data-duration");
const trackStr = readAttr(tag.raw, "data-track-index");
if (!startStr || !durationStr || !trackStr) continue;

const start = Number(startStr);
const duration = Number(durationStr);
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
if (!trackStr) continue;
const timing = readTagTiming(tag.raw);
const { start, duration } = timing;
const track = trackStr;

// Skip non-numeric (relative timing references like "intro-comp")
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
if (start == null || duration == null) continue;

const clips = trackMap.get(track) || [];
clips.push({
Expand Down
10 changes: 10 additions & 0 deletions packages/parsers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@
"import": "./src/composition.ts",
"types": "./src/composition.ts"
},
"./composition-contract": {
"bun": "./src/compositionContract.ts",
"node": "./dist/compositionContract.js",
"import": "./src/compositionContract.ts",
"types": "./src/compositionContract.ts"
},
"./sub-composition-validity": {
"bun": "./src/subCompositionValidity.ts",
"node": "./dist/subCompositionValidity.js",
Expand Down Expand Up @@ -137,6 +143,10 @@
"import": "./dist/composition.js",
"types": "./dist/composition.d.ts"
},
"./composition-contract": {
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
},
"./sub-composition-validity": {
"import": "./dist/subCompositionValidity.js",
"types": "./dist/subCompositionValidity.d.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export {
isScalarVariableValue,
} from "./compositionVariables.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
export * from "./compositionContract.js";
Loading
Loading