diff --git a/packages/core/package.json b/packages/core/package.json index 85ac59c276..c5cda44752 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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" diff --git a/packages/core/src/compositionContract.ts b/packages/core/src/compositionContract.ts new file mode 100644 index 0000000000..05fd587f90 --- /dev/null +++ b/packages/core/src/compositionContract.ts @@ -0,0 +1 @@ +export * from "@hyperframes/parsers/composition-contract"; diff --git a/packages/core/src/generators/hyperframes.test.ts b/packages/core/src/generators/hyperframes.test.ts index a8524b1443..d14dae9a5d 100644 --- a/packages/core/src/generators/hyperframes.test.ts +++ b/packages/core/src/generators/hyperframes.test.ts @@ -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", () => { diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts index 4b8db6376b..f6cd2eac4a 100644 --- a/packages/core/src/generators/hyperframes.ts +++ b/packages/core/src/generators/hyperframes.ts @@ -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 = { @@ -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}"`, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a7a0ae578..8688c7d56a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/runtime/protocol.ts b/packages/core/src/runtime/protocol.ts index c138b236f1..6c2af46250 100644 --- a/packages/core/src/runtime/protocol.ts +++ b/packages/core/src/runtime/protocol.ts @@ -4,6 +4,7 @@ export const RUNTIME_PROTOCOL_CAPABILITIES = [ "seconds-time", "rational-fps", "seek-keep-playing", + "composition-manifest-v1", ] as const; export type RuntimeProtocolFps = { diff --git a/packages/core/src/runtime/startExpression.ts b/packages/core/src/runtime/startExpression.ts index 4756595eff..3ed510a395 100644 --- a/packages/core/src/runtime/startExpression.ts +++ b/packages/core/src/runtime/startExpression.ts @@ -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"; diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index f1d08dfa71..86483dd925 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -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 diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 0cd123fc0c..59d79a18d3 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -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"; @@ -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, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index f53a80534d..de52422891 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -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[]; diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 60c90bd361..0eee0ee4e9 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -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(` +
+
+
+ `); + + 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(` +
+
+
+
+ `); + + 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) => diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 0a52be380f..a15c701c47 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -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. @@ -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; @@ -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); } @@ -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", @@ -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", @@ -435,17 +441,14 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding const trackMap = new Map(); 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({ diff --git a/packages/parsers/package.json b/packages/parsers/package.json index b82ff098d9..2315d4d94c 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -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", @@ -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" diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts index 6ed6ab617c..ac078646ba 100644 --- a/packages/parsers/src/composition.ts +++ b/packages/parsers/src/composition.ts @@ -16,3 +16,4 @@ export { isScalarVariableValue, } from "./compositionVariables.js"; export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js"; +export * from "./compositionContract.js"; diff --git a/packages/parsers/src/compositionContract.test.ts b/packages/parsers/src/compositionContract.test.ts new file mode 100644 index 0000000000..f6c7786dcd --- /dev/null +++ b/packages/parsers/src/compositionContract.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + COMPOSITION_ATTRIBUTES, + parseStartExpression, + readClipTiming, + writeClipTiming, +} from "./compositionContract"; + +class Attributes { + readonly values = new Map(); + + constructor(values: Record) { + for (const [name, value] of Object.entries(values)) this.values.set(name, value); + } + + getAttribute(name: string): string | null { + return this.values.get(name) ?? null; + } + + setAttribute(name: string, value: string): void { + this.values.set(name, value); + } + + removeAttribute(name: string): void { + this.values.delete(name); + } +} + +describe("composition timing contract", () => { + it.each([ + ["1.25", { kind: "absolute", value: 1.25 }], + ["intro", { kind: "reference", refId: "intro", offset: 0 }], + ["intro + 2", { kind: "reference", refId: "intro", offset: 2 }], + ["intro - .5", { kind: "reference", refId: "intro", offset: -0.5 }], + ["intro + nope", null], + ["Infinity", { kind: "reference", refId: "Infinity", offset: 0 }], + ])("parses start expression %s", (raw, expected) => { + expect(parseStartExpression(raw)).toEqual(expected); + }); + + it("matches runtime clamping for negative absolute and reference starts", () => { + expect(readClipTiming(new Attributes({ "data-start": "-2", "data-duration": "1" })).start).toBe( + 0, + ); + expect( + readClipTiming(new Attributes({ "data-start": "intro - 5", "data-duration": "1" }), { + resolveReferenceEnd: () => 2, + }).start, + ).toBe(0); + }); + + it.each([ + { + name: "canonical fractional timing", + attrs: { "data-start": "1.25", "data-duration": "2.5", "data-track-index": "3" }, + expected: { start: 1.25, duration: 2.5, end: 3.75, trackIndex: 3 }, + codes: [], + }, + { + name: "resolved reference", + attrs: { "data-start": "intro - .5", "data-duration": "2" }, + expected: { start: 3.5, duration: 2, end: 5.5, trackIndex: 0 }, + codes: [], + resolve: (id: string) => (id === "intro" ? 4 : null), + }, + { + name: "legacy attributes", + attrs: { "data-start": "1", "data-end": "4", "data-layer": "2" }, + expected: { start: 1, duration: 3, end: 4, trackIndex: 2 }, + codes: ["deprecated-end", "deprecated-layer"], + }, + { + name: "canonical values win over conflicting legacy values", + attrs: { + "data-start": "1", + "data-duration": "2", + "data-end": "9", + "data-track-index": "1", + "data-layer": "4", + }, + expected: { start: 1, duration: 2, end: 3, trackIndex: 1 }, + codes: ["deprecated-end", "conflicting-end", "deprecated-layer", "conflicting-layer"], + }, + { + name: "invalid values are diagnosed rather than coerced", + attrs: { "data-start": "wat +", "data-duration": "-1", "data-track-index": "1.5" }, + expected: { start: null, duration: null, end: null, trackIndex: 0 }, + codes: ["invalid-start", "invalid-duration", "invalid-track-index"], + }, + ])("reads $name", ({ attrs, expected, codes, resolve }) => { + const result = readClipTiming(new Attributes(attrs), { resolveReferenceEnd: resolve }); + expect(result).toMatchObject(expected); + expect(result.diagnostics.map(({ code }) => code)).toEqual(codes); + }); + + it("canonicalizes legacy timing during a mutation and round-trips semantics", () => { + const attrs = new Attributes({ + "data-start": "1", + "data-end": "4", + "data-layer": "2", + }); + + const written = writeClipTiming(attrs, { start: 2, duration: 4, trackIndex: 5 }); + + expect(written).toMatchObject({ start: 2, duration: 4, end: 6, trackIndex: 5 }); + expect(attrs.values).toEqual( + new Map([ + [COMPOSITION_ATTRIBUTES.start, "2"], + [COMPOSITION_ATTRIBUTES.duration, "4"], + [COMPOSITION_ATTRIBUTES.trackIndex, "5"], + ]), + ); + }); + + it("preserves a reference expression when writing canonical duration/track fields", () => { + const attrs = new Attributes({ + "data-start": "intro + 1", + "data-end": "8", + "data-layer": "2", + }); + + writeClipTiming(attrs, { duration: 3, trackIndex: 4 }); + + expect(attrs.getAttribute("data-start")).toBe("intro + 1"); + expect(attrs.getAttribute("data-duration")).toBe("3"); + expect(attrs.getAttribute("data-track-index")).toBe("4"); + expect(attrs.getAttribute("data-end")).toBeNull(); + expect(attrs.getAttribute("data-layer")).toBeNull(); + }); + + it("preserves a legacy end when a track-only edit cannot resolve reference duration", () => { + const attrs = new Attributes({ + "data-start": "intro + 1", + "data-end": "8", + "data-layer": "2", + }); + + writeClipTiming(attrs, { trackIndex: 4 }); + + expect(attrs.getAttribute("data-start")).toBe("intro + 1"); + expect(attrs.getAttribute("data-duration")).toBeNull(); + expect(attrs.getAttribute("data-end")).toBe("8"); + expect(attrs.getAttribute("data-track-index")).toBe("4"); + expect(attrs.getAttribute("data-layer")).toBeNull(); + }); +}); diff --git a/packages/parsers/src/compositionContract.ts b/packages/parsers/src/compositionContract.ts new file mode 100644 index 0000000000..8079314776 --- /dev/null +++ b/packages/parsers/src/compositionContract.ts @@ -0,0 +1,395 @@ +/** + * Browser-safe authored composition contract. + * + * Source HTML is authored with data-start + data-duration + data-track-index. + * data-end is compiler-derived and data-layer is legacy input only. Readers + * accept legacy documents; writers always emit the canonical representation. + */ + +export const COMPOSITION_CONTRACT_VERSION = 1 as const; + +export const COMPOSITION_ATTRIBUTES = Object.freeze({ + start: "data-start", + duration: "data-duration", + trackIndex: "data-track-index", + derivedEnd: "data-end", + legacyTrack: "data-layer", +} as const); + +export const CANONICAL_AUTHORED_TIMING_ATTRIBUTES = Object.freeze([ + COMPOSITION_ATTRIBUTES.start, + COMPOSITION_ATTRIBUTES.duration, + COMPOSITION_ATTRIBUTES.trackIndex, +] as const); + +export const DERIVED_TIMING_ATTRIBUTES = Object.freeze([ + COMPOSITION_ATTRIBUTES.derivedEnd, +] as const); + +export const LEGACY_TIMING_ATTRIBUTES = Object.freeze([ + COMPOSITION_ATTRIBUTES.derivedEnd, + COMPOSITION_ATTRIBUTES.legacyTrack, +] as const); + +export type ReferenceExpression = + | { kind: "absolute"; value: number } + | { kind: "reference"; refId: string; offset: number }; + +export type ClipTimingDiagnosticCode = + | "invalid-start" + | "unresolved-start-reference" + | "invalid-duration" + | "invalid-end" + | "end-before-start" + | "deprecated-end" + | "conflicting-end" + | "invalid-track-index" + | "deprecated-layer" + | "conflicting-layer"; + +export interface ClipTimingDiagnostic { + code: ClipTimingDiagnosticCode; + attribute: string; + value: string | null; +} + +export interface ClipAttributeReader { + getAttribute(name: string): string | null; +} + +export interface ClipAttributeWriter extends ClipAttributeReader { + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; +} + +export interface ReadClipTimingOptions { + /** Resolve a reference to the referenced clip's absolute end time. */ + resolveReferenceEnd?: (refId: string) => number | null | undefined; + /** Start used when data-start is absent. Defaults to zero. */ + defaultStart?: number | null; +} + +export interface ClipTiming { + startExpression: ReferenceExpression | null; + start: number | null; + duration: number | null; + end: number | null; + trackIndex: number; + durationSource: "duration" | "legacy-end" | "missing" | "invalid"; + trackSource: "track-index" | "legacy-layer" | "default" | "invalid"; + diagnostics: ClipTimingDiagnostic[]; +} + +export interface ClipTimingUpdate { + start?: number | string | ReferenceExpression; + duration?: number | null; + trackIndex?: number | null; +} + +export class ClipTimingWriteError extends Error { + readonly code: "invalid-start" | "invalid-duration" | "invalid-track-index"; + + constructor(code: ClipTimingWriteError["code"], message: string) { + super(message); + this.name = "ClipTimingWriteError"; + this.code = code; + } +} + +/** Parse a value to a finite number, or null if it is absent/invalid. */ +export function parseNumeric(value: string | null | undefined): number | null { + if (value == null || value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Parse the data-start grammar: absolute seconds, `clip-id`, or + * `clip-id +/- offset`, where references resolve to the referenced clip's end. + */ +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 match = normalized.match(/^([A-Za-z0-9_.:-]+)(?:\s*([+-])\s*([0-9]*\.?[0-9]+))?$/); + if (!match) return null; + const refId = (match[1] ?? "").trim(); + if (!refId) return null; + const magnitude = Number(match[3] ?? "0"); + if (!Number.isFinite(magnitude) || magnitude < 0) return null; + return { + kind: "reference", + refId, + offset: match[2] === "-" ? -magnitude : magnitude, + }; +} + +function pushDiagnostic( + diagnostics: ClipTimingDiagnostic[], + code: ClipTimingDiagnosticCode, + attribute: string, + value: string | null, +): void { + diagnostics.push({ code, attribute, value }); +} + +function resolveStart( + expression: ReferenceExpression | null, + rawStart: string | null, + options: ReadClipTimingOptions, + diagnostics: ClipTimingDiagnostic[], +): number | null { + if (rawStart == null || rawStart.trim() === "") { + return options.defaultStart === undefined ? 0 : options.defaultStart; + } + if (!expression) { + pushDiagnostic(diagnostics, "invalid-start", COMPOSITION_ATTRIBUTES.start, rawStart); + return null; + } + if (expression.kind === "absolute") return Math.max(0, expression.value); + const referencedEnd = options.resolveReferenceEnd?.(expression.refId); + if (referencedEnd == null || !Number.isFinite(referencedEnd)) { + pushDiagnostic( + diagnostics, + "unresolved-start-reference", + COMPOSITION_ATTRIBUTES.start, + rawStart, + ); + return null; + } + return Math.max(0, referencedEnd + expression.offset); +} + +type DurationRead = Pick; +type TrackRead = Pick; + +function diagnoseDerivedEnd( + rawEnd: string, + canonicalEnd: number | null, + diagnostics: ClipTimingDiagnostic[], +): void { + pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + const parsedEnd = parseNumeric(rawEnd); + if (parsedEnd == null) { + pushDiagnostic(diagnostics, "invalid-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + } else if (canonicalEnd != null && parsedEnd !== canonicalEnd) { + pushDiagnostic(diagnostics, "conflicting-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + } +} + +function readCanonicalDuration( + rawDuration: string, + start: number | null, + diagnostics: ClipTimingDiagnostic[], +): DurationRead { + const duration = parseNumeric(rawDuration); + if (duration == null || duration < 0) { + pushDiagnostic(diagnostics, "invalid-duration", COMPOSITION_ATTRIBUTES.duration, rawDuration); + return { duration: null, end: null, durationSource: "invalid" }; + } + return { + duration, + end: start == null ? null : start + duration, + durationSource: "duration", + }; +} + +function readLegacyEnd( + rawEnd: string, + start: number | null, + diagnostics: ClipTimingDiagnostic[], +): DurationRead { + pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + const end = parseNumeric(rawEnd); + if (end == null) { + pushDiagnostic(diagnostics, "invalid-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + return { duration: null, end: null, durationSource: "invalid" }; + } + if (start == null) return { duration: null, end, durationSource: "legacy-end" }; + if (end < start) { + pushDiagnostic(diagnostics, "end-before-start", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd); + return { duration: null, end: null, durationSource: "invalid" }; + } + return { duration: end - start, end, durationSource: "legacy-end" }; +} + +function readDuration( + attributes: ClipAttributeReader, + start: number | null, + diagnostics: ClipTimingDiagnostic[], +): DurationRead { + const rawDuration = attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration); + const rawEnd = attributes.getAttribute(COMPOSITION_ATTRIBUTES.derivedEnd); + if (rawDuration == null) { + return rawEnd == null + ? { duration: null, end: null, durationSource: "missing" } + : readLegacyEnd(rawEnd, start, diagnostics); + } + const canonical = readCanonicalDuration(rawDuration, start, diagnostics); + if (rawEnd != null) diagnoseDerivedEnd(rawEnd, canonical.end, diagnostics); + return canonical; +} + +function readTrackValue( + rawValue: string, + attribute: string, + source: "track-index" | "legacy-layer", + diagnostics: ClipTimingDiagnostic[], +): TrackRead { + const trackIndex = parseNumeric(rawValue); + if (trackIndex == null || !Number.isInteger(trackIndex)) { + pushDiagnostic(diagnostics, "invalid-track-index", attribute, rawValue); + return { trackIndex: 0, trackSource: "invalid" }; + } + return { trackIndex, trackSource: source }; +} + +function readTrack( + attributes: ClipAttributeReader, + diagnostics: ClipTimingDiagnostic[], +): TrackRead { + const rawTrack = attributes.getAttribute(COMPOSITION_ATTRIBUTES.trackIndex); + const rawLayer = attributes.getAttribute(COMPOSITION_ATTRIBUTES.legacyTrack); + if (rawTrack == null && rawLayer == null) return { trackIndex: 0, trackSource: "default" }; + if (rawTrack == null && rawLayer != null) { + pushDiagnostic(diagnostics, "deprecated-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer); + return readTrackValue( + rawLayer, + COMPOSITION_ATTRIBUTES.legacyTrack, + "legacy-layer", + diagnostics, + ); + } + const canonical = readTrackValue( + rawTrack ?? "", + COMPOSITION_ATTRIBUTES.trackIndex, + "track-index", + diagnostics, + ); + if (rawLayer == null) return canonical; + pushDiagnostic(diagnostics, "deprecated-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer); + const parsedLayer = parseNumeric(rawLayer); + if (parsedLayer != null && parsedLayer !== canonical.trackIndex) { + pushDiagnostic(diagnostics, "conflicting-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer); + } + return canonical; +} + +/** Read canonical timing, accepting legacy attributes without preferring them. */ +export function readClipTiming( + attributes: ClipAttributeReader, + options: ReadClipTimingOptions = {}, +): ClipTiming { + const diagnostics: ClipTimingDiagnostic[] = []; + const rawStart = attributes.getAttribute(COMPOSITION_ATTRIBUTES.start); + const startExpression = parseStartExpression(rawStart); + const start = resolveStart(startExpression, rawStart, options, diagnostics); + const duration = readDuration(attributes, start, diagnostics); + const track = readTrack(attributes, diagnostics); + return { startExpression, start, ...duration, ...track, diagnostics }; +} + +function serializeStartNumber(start: number): string { + if (!Number.isFinite(start)) { + throw new ClipTimingWriteError("invalid-start", "start must be finite"); + } + return String(start); +} + +function serializeStartReference(start: ReferenceExpression): string { + if (start.kind === "absolute") return serializeStartNumber(start.value); + if (!start.refId || !Number.isFinite(start.offset)) { + throw new ClipTimingWriteError( + "invalid-start", + "reference start must have an id and finite offset", + ); + } + if (start.offset === 0) return start.refId; + return `${start.refId} ${start.offset < 0 ? "-" : "+"} ${Math.abs(start.offset)}`; +} + +function serializeStart(start: ClipTimingUpdate["start"]): string { + if (typeof start === "number") return serializeStartNumber(start); + if (typeof start === "object" && start != null) return serializeStartReference(start); + if (typeof start === "string" && parseStartExpression(start)) return start.trim(); + throw new ClipTimingWriteError("invalid-start", `invalid start expression: ${start ?? ""}`); +} + +function writeDuration( + attributes: ClipAttributeWriter, + duration: number | null | undefined, + current: ClipTiming, +): void { + if (duration === null) { + attributes.removeAttribute(COMPOSITION_ATTRIBUTES.duration); + return; + } + if (duration === undefined) { + if ( + attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration) == null && + current.duration != null + ) { + attributes.setAttribute(COMPOSITION_ATTRIBUTES.duration, String(current.duration)); + } + return; + } + if (!Number.isFinite(duration) || duration < 0) { + throw new ClipTimingWriteError( + "invalid-duration", + "duration must be a finite, non-negative number", + ); + } + attributes.setAttribute(COMPOSITION_ATTRIBUTES.duration, String(duration)); +} + +function writeTrack( + attributes: ClipAttributeWriter, + trackIndex: number | null | undefined, + current: ClipTiming, +): void { + if (trackIndex === null) { + attributes.removeAttribute(COMPOSITION_ATTRIBUTES.trackIndex); + return; + } + if (trackIndex === undefined) { + if ( + attributes.getAttribute(COMPOSITION_ATTRIBUTES.trackIndex) == null && + current.trackSource === "legacy-layer" + ) { + attributes.setAttribute(COMPOSITION_ATTRIBUTES.trackIndex, String(current.trackIndex)); + } + return; + } + if (!Number.isFinite(trackIndex) || !Number.isInteger(trackIndex)) { + throw new ClipTimingWriteError("invalid-track-index", "trackIndex must be a finite integer"); + } + attributes.setAttribute(COMPOSITION_ATTRIBUTES.trackIndex, String(trackIndex)); +} + +/** Write only canonical authored timing and remove derived/legacy source attributes. */ +export function writeClipTiming( + attributes: ClipAttributeWriter, + update: ClipTimingUpdate, +): ClipTiming { + const current = readClipTiming(attributes); + if (update.start !== undefined) { + attributes.setAttribute(COMPOSITION_ATTRIBUTES.start, serializeStart(update.start)); + } + writeDuration(attributes, update.duration, current); + writeTrack(attributes, update.trackIndex, current); + // A legacy end paired with an unresolved reference start cannot be converted + // to data-duration without a reference resolver. On a duration-omitting edit + // (for example, moving only the track), preserve that sole duration source + // instead of canonicalizing it into data loss. + const preserveUnresolvedLegacyEnd = + update.duration === undefined && + attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration) == null && + attributes.getAttribute(COMPOSITION_ATTRIBUTES.derivedEnd) != null && + current.duration == null; + if (!preserveUnresolvedLegacyEnd) { + attributes.removeAttribute(COMPOSITION_ATTRIBUTES.derivedEnd); + } + attributes.removeAttribute(COMPOSITION_ATTRIBUTES.legacyTrack); + return readClipTiming(attributes); +} diff --git a/packages/parsers/src/htmlParser.test.ts b/packages/parsers/src/htmlParser.test.ts index d1cc537c8b..5bf9519ebe 100644 --- a/packages/parsers/src/htmlParser.test.ts +++ b/packages/parsers/src/htmlParser.test.ts @@ -37,6 +37,29 @@ describe("parseHtml", () => { expect(result.elements[1].duration).toBe(5); }); + it("prefers canonical duration/track attributes over conflicting legacy values", () => { + const result = parseHtml(` +
+
Clip
+
+ `); + + expect(result.elements[0]).toMatchObject({ startTime: 1, duration: 2.5, zIndex: 3 }); + }); + + it("resolves chained start references with the shared grammar", () => { + const result = parseHtml(` +
+
Intro
+
Body
+
Outro
+
+ `); + + expect(result.elements.find(({ name }) => name === "Body")?.startTime).toBe(2.5); + expect(result.elements.find(({ name }) => name === "Outro")?.startTime).toBe(5.5); + }); + it("handles nested compositions", () => { const html = ` @@ -464,7 +487,8 @@ describe("updateElementInHtml", () => { const updated = updateElementInHtml(html, "el1", { startTime: 2, duration: 3 }); expect(updated).toContain('data-start="2"'); - expect(updated).toContain('data-end="5"'); // data-end gets set to start + duration + expect(updated).toContain('data-duration="3"'); + expect(updated).not.toContain('data-end="'); }); it("updates element name", () => { @@ -511,7 +535,10 @@ describe("addElementToHtml", () => { expect(id).toBeDefined(); expect(updated).toContain(`id="${id}"`); expect(updated).toContain('data-start="1"'); - expect(updated).toContain('data-end="4"'); + expect(updated).toContain('data-duration="3"'); + expect(updated).toContain('data-track-index="1"'); + expect(updated).not.toContain('data-end="'); + expect(updated).not.toContain('data-layer="'); expect(updated).toContain("Hello!"); }); diff --git a/packages/parsers/src/htmlParser.ts b/packages/parsers/src/htmlParser.ts index 4931183877..92865de4eb 100644 --- a/packages/parsers/src/htmlParser.ts +++ b/packages/parsers/src/htmlParser.ts @@ -17,6 +17,7 @@ import { ensureHfIds, walkCompositionDescendants } from "./hfIds.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; import { queryByAttr } from "./utils/cssSelector.js"; import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; +import { readClipTiming, writeClipTiming } from "./compositionContract.js"; const MEDIA_TYPES = new Set(["video", "image", "audio"]); @@ -88,8 +89,10 @@ function getElementName(el: Element): string { } function getZIndex(el: Element): number { - const dataLayer = el.getAttribute("data-layer"); - if (dataLayer) return parseInt(dataLayer, 10) || 0; + const timing = readClipTiming(el); + if (timing.trackSource !== "default" && timing.trackSource !== "invalid") { + return timing.trackIndex; + } const style = (el as HTMLElement).style?.zIndex; if (style) return parseInt(style, 10) || 0; @@ -197,21 +200,35 @@ export function parseHtml(html: string): ParsedHtml { } } - const timedElements = doc.querySelectorAll("[data-start]"); + const timedElements = Array.from(doc.querySelectorAll("[data-start]")); + const timedById = new Map(); + for (const element of timedElements) { + for (const id of [element.id, element.getAttribute("data-hf-id")]) { + if (id) timedById.set(id, element); + } + } + + const resolveEnd = (refId: string, visiting: ReadonlySet): number | null => { + if (visiting.has(refId)) return null; + const referenced = timedById.get(refId); + if (!referenced) return null; + const next = new Set(visiting); + next.add(refId); + return readClipTiming(referenced, { + resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next), + }).end; + }; timedElements.forEach((el) => { const type = getElementType(el); if (!type) return; - const start = parseFloat(el.getAttribute("data-start") || "0"); - const dataEnd = el.getAttribute("data-end"); - - let duration: number; - if (dataEnd) { - duration = Math.max(0, parseFloat(dataEnd) - start); - } else { - duration = 5; - } + const ownId = el.id || el.getAttribute("data-hf-id"); + const timing = readClipTiming(el, { + resolveReferenceEnd: (refId) => resolveEnd(refId, new Set(ownId ? [ownId] : [])), + }); + const start = timing.start ?? 0; + const duration = timing.duration ?? 5; // R1: stable hf- id minted by ensureHfIds above; clips just read it. // Legacy/migration note: ensureHfIds pins a pre-existing `data-hf-id`, and @@ -544,27 +561,22 @@ export function updateElementInHtml( const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId); if (!el) return html; - if (updates.startTime !== undefined) { - el.setAttribute("data-start", String(updates.startTime)); - if (el.hasAttribute("data-end") && updates.duration !== undefined) { - el.setAttribute("data-end", String(updates.startTime + updates.duration)); - } - } - - if (updates.duration !== undefined) { - const start = parseFloat(el.getAttribute("data-start") || "0"); - el.setAttribute("data-end", String(start + updates.duration)); - el.removeAttribute("data-duration"); // Clean up legacy + if ( + updates.startTime !== undefined || + updates.duration !== undefined || + updates.zIndex !== undefined + ) { + writeClipTiming(el, { + start: updates.startTime, + duration: updates.duration, + trackIndex: updates.zIndex, + }); } if (updates.name !== undefined) { el.setAttribute("data-name", updates.name); } - if (updates.zIndex !== undefined) { - el.setAttribute("data-layer", String(updates.zIndex)); - } - // Handle media-specific property if ("src" in updates && updates.src !== undefined) { el.setAttribute("src", updates.src); @@ -698,9 +710,11 @@ export function addElementToHtml( } newEl.id = id; - newEl.setAttribute("data-start", String(element.startTime)); - newEl.setAttribute("data-end", String(element.startTime + element.duration)); - newEl.setAttribute("data-layer", String(element.zIndex)); + writeClipTiming(newEl, { + start: element.startTime, + duration: element.duration, + trackIndex: element.zIndex, + }); newEl.setAttribute("data-name", element.name); container.appendChild(newEl); diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index 57a8f625b4..2baf100c85 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -6,6 +6,7 @@ export * from "./subCompositionValidity.js"; export * from "./outputResolutionCompatibility.js"; export { unrollComputedTimeline } from "./gsapUnroll.js"; export { queryByAttr } from "./utils/cssSelector.js"; +export * from "./compositionContract.js"; // Pure, browser-safe composition primitives shared by the linter (so it can // consume them without depending on @hyperframes/core). The Node-only asset diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts index 62c3c4957e..f970e38bff 100644 --- a/packages/parsers/tsup.config.ts +++ b/packages/parsers/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ slideshow: "src/slideshow/index.ts", assets: "src/assets.ts", composition: "src/composition.ts", + compositionContract: "src/compositionContract.ts", subCompositionValidity: "src/subCompositionValidity.ts", }, format: ["esm"], diff --git a/packages/player/src/parent-media.ts b/packages/player/src/parent-media.ts index 5236b7a6cb..7311461c93 100644 --- a/packages/player/src/parent-media.ts +++ b/packages/player/src/parent-media.ts @@ -11,6 +11,7 @@ import { selectMediaObserverTargets } from "./mediaObserverScope.js"; import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.js"; +import { readClipTiming } from "@hyperframes/core/composition-contract"; /** Minimum absolute drift before a currentTime correction is attempted. */ const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05; @@ -149,10 +150,10 @@ export class ParentMediaManager { // Guard against a malformed (non-numeric) attribute parsing to NaN: an NaN // duration makes every `relTime >= m.duration` window check false, so the // gate never closes and the proxy plays past its clip end. - const start = parseFloat(m.source.getAttribute("data-start") || "0"); - m.start = Number.isFinite(start) ? start : 0; - const duration = parseFloat(m.source.getAttribute("data-duration") || ""); - m.duration = Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY; + const timing = readClipTiming(m.source); + m.start = timing.start ?? 0; + m.duration = + timing.duration != null && timing.duration > 0 ? timing.duration : Number.POSITIVE_INFINITY; } // Pause the proxy outside its clip window; resume it on re-entry during @@ -382,8 +383,9 @@ export class ParentMediaManager { const src = this._resolveIframeMediaSrc(iframeEl); if (!src) return; - const start = parseFloat(iframeEl.getAttribute("data-start") || "0"); - const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity"); + const timing = readClipTiming(iframeEl); + const start = timing.start ?? 0; + const duration = timing.duration ?? Number.POSITIVE_INFINITY; const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const); const created = this._createEntry(src, tag, start, duration, iframeEl); diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index 569bf9f8b3..e56269973b 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -151,6 +151,25 @@ describe("handleRuntimeMessage timeline ready", () => { ); }); + it("prefers exact manifest seconds and dimensions over derived probes", () => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + + handleRuntimeMessage( + timelineEvent(999, frameWindow, { + ...runtimeProtocolMetadata(60), + durationSeconds: 4.25, + compositionWidth: 1080, + compositionHeight: 1920, + }), + frameWindow, + callbacks, + ); + + expect(callbacks.onRuntimeTimelineReady).toHaveBeenCalledWith(4.25); + expect(callbacks.setCompositionSize).toHaveBeenCalledWith(1080, 1920); + }); + it("rejects unknown protocol majors with a public diagnostic event", () => { const frameWindow = {} as Window; const callbacks = makeCallbacks(); diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index d927e53e73..76b6421d1b 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -120,13 +120,29 @@ export function handleRuntimeMessage( } if (data["type"] === "timeline" && (data["durationInFrames"] as number) > 0) { - if (Number.isFinite(data["durationInFrames"])) { + const declaredDuration = Number(data["durationSeconds"]); + const frameDuration = Number(data["durationInFrames"]); + const duration = + Number.isFinite(declaredDuration) && declaredDuration > 0 + ? declaredDuration + : frameDuration / protocol.fps; + if (Number.isFinite(duration) && duration > 0) { const pb = callbacks.getPlaybackState(); - const duration = (data["durationInFrames"] as number) / protocol.fps; callbacks.setPlaybackState({ ...pb, duration }); callbacks.updateControlsTime(pb.currentTime, duration); callbacks.onRuntimeTimelineReady(duration); } + if ( + Number.isFinite(data["compositionWidth"]) && + (data["compositionWidth"] as number) > 0 && + Number.isFinite(data["compositionHeight"]) && + (data["compositionHeight"] as number) > 0 + ) { + callbacks.setCompositionSize( + data["compositionWidth"] as number, + data["compositionHeight"] as number, + ); + } callbacks.setScenes(extractScenes(data["scenes"])); return; } diff --git a/packages/sdk/src/adapters/iframe.sync.test.ts b/packages/sdk/src/adapters/iframe.sync.test.ts index 9e28689572..800d8b88dd 100644 --- a/packages/sdk/src/adapters/iframe.sync.test.ts +++ b/packages/sdk/src/adapters/iframe.sync.test.ts @@ -237,7 +237,7 @@ window.__timelines = { t: tl }; expect(liveDocEl.getAttribute("data-composition-variables") ?? "").not.toContain("accent"); }); - it("mirrors setTiming onto the live element's data-start/data-end attributes", async () => { + it("mirrors canonical setTiming attributes onto the live element", async () => { const iframe = mountIframe(BASE_HTML); const comp = await openComposition(BASE_HTML); const adapter = createIframePreviewAdapter(iframe); @@ -249,6 +249,7 @@ window.__timelines = { t: tl }; '[data-hf-id="hf-title"]', ) as HTMLElement; expect(liveTitle.getAttribute("data-start")).toBe("1"); - expect(liveTitle.getAttribute("data-end")).toBe("3"); + expect(liveTitle.getAttribute("data-duration")).toBe("2"); + expect(liveTitle.getAttribute("data-end")).toBeNull(); }); }); diff --git a/packages/sdk/src/engine/mutate.gsap.test.ts b/packages/sdk/src/engine/mutate.gsap.test.ts index 20eeb893e9..5083ae1c2b 100644 --- a/packages/sdk/src/engine/mutate.gsap.test.ts +++ b/packages/sdk/src/engine/mutate.gsap.test.ts @@ -1081,7 +1081,7 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => { expect(script).not.toMatch(/tl\.to\("#box",[^)]*\}, \d/); }); - it("R5 #7: a clip with BOTH data-duration and data-end keeps data-end in sync on move", () => { + it("canonicalizes a clip carrying both authored duration and derived end", () => { const parsed = timingDoc( `data-start="1" data-duration="2" data-end="3"`, `tl.to("#box", { x: 1, duration: 2 }, 1);`, @@ -1090,8 +1090,8 @@ describe("handleSetTiming GSAP sync (CF2 #15/#16)", () => { const el = parsed.document.querySelector('[data-hf-id="hf-box"]'); expect(el?.getAttribute("data-start")).toBe("5"); expect(el?.getAttribute("data-duration")).toBe("2"); - // data-end recomputed (5 + 2); the bug left it stale at 3 → inverted clip. - expect(el?.getAttribute("data-end")).toBe("7"); + // Source mutations retain authored duration and remove compiler-derived end. + expect(el?.getAttribute("data-end")).toBeNull(); }); }); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 28d08462bf..62464bce28 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -321,7 +321,8 @@ describe("setTiming", () => { const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); expect(el?.getAttribute("data-start")).toBe("1"); // duration was 3 (0→3), so end = 1+3 = 4 - expect(el?.getAttribute("data-end")).toBe("4"); + expect(el?.getAttribute("data-duration")).toBe("3"); + expect(el?.getAttribute("data-end")).toBeNull(); const startPatch = result.forward.find((p) => p.path.endsWith("/start")); expect(startPatch?.value).toBe(1); }); @@ -330,12 +331,12 @@ describe("setTiming", () => { const parsed = fresh(); applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 }); const el = parsed.document.querySelector('[data-hf-id="hf-title"]'); - expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2 + expect(el?.getAttribute("data-duration")).toBe("2"); + expect(el?.getAttribute("data-end")).toBeNull(); }); it("inverse patches restore original timing", () => { const parsed = fresh(); - const before = serializeDocument(parsed); const { inverse } = applyOp(parsed, { type: "setTiming", target: "hf-title", @@ -344,7 +345,11 @@ describe("setTiming", () => { trackIndex: 1, }); applyPatchesToDocument(parsed, inverse); - expect(serializeDocument(parsed)).toBe(before); + const restored = parsed.document.querySelector('[data-hf-id="hf-title"]'); + expect(restored?.getAttribute("data-start")).toBe("0"); + expect(restored?.getAttribute("data-duration")).toBeNull(); + expect(restored?.getAttribute("data-end")).toBe("3"); + expect(restored?.getAttribute("data-track-index")).toBe("0"); }); }); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index d830dbc22b..adfa85f3c5 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -54,6 +54,7 @@ import { import { upsertCssRule } from "./cssWriter.js"; import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids"; import { EDIT_BASE_X_ATTR, EDIT_BASE_Y_ATTR } from "@hyperframes/core/runtime/position-edits"; +import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { @@ -462,84 +463,80 @@ function handleSetTiming( const el = resolveScoped(parsed.document, id); if (!el) continue; - const oldStartStr = el.getAttribute("data-start"); - const oldEndStr = el.getAttribute("data-end"); - const oldDurationStr = el.getAttribute("data-duration"); - const oldTrackStr = el.getAttribute("data-track-index"); - - const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null; - const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null; - const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null; - // Prefer an explicit data-duration — the attribute clips are authored with and - // the runtime reads — falling back to data-end − data-start. Reading only - // data-end left oldDuration null for duration-authored clips, collapsing the - // GSAP duration-scale ratio to 1 and scaling nothing. - const oldDuration = - oldDurationAttr !== null - ? oldDurationAttr - : oldStart !== null && oldEnd !== null - ? oldEnd - oldStart - : null; - const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null; + const beforeAttributes = { + start: el.getAttribute("data-start"), + duration: el.getAttribute("data-duration"), + end: el.getAttribute("data-end"), + trackIndex: el.getAttribute("data-track-index"), + layer: el.getAttribute("data-layer"), + }; + const oldTiming = readClipTiming(el); + const oldStart = oldTiming.start; + const oldDuration = oldTiming.duration; const newStart = timing.start ?? oldStart; const newDuration = timing.duration ?? oldDuration; - if (timing.start !== undefined && newStart !== null) { - const path = timingPath(id, "start"); - const p = scalarChange(path, oldStart, newStart); - result.forward.push(p.forward); - result.inverse.push(p.inverse); - el.setAttribute("data-start", String(newStart)); - } - - // Write to whichever timing attribute the clip actually uses. A data-duration - // clip updates data-duration only on a real resize (duration is invariant - // under a move); a data-end clip updates data-end whenever start or duration - // changes (end = start + duration). Writing a fresh data-end beside a stale - // data-duration had no playback effect. - if (oldDurationStr !== null) { - if (timing.duration !== undefined && newDuration !== null) { - const path = timingPath(id, "duration"); - const p = scalarChange(path, oldDurationAttr, newDuration); - result.forward.push(p.forward); - result.inverse.push(p.inverse); - el.setAttribute("data-duration", String(newDuration)); - } - // A clip carrying BOTH data-duration and data-end must keep data-end in - // sync (end = start + duration) on any start/duration change, else the - // stale data-end inverts the clip (end < start) for runtimes that read it. - if (oldEndStr !== null && newStart !== null && newDuration !== null) { - const newEnd = newStart + newDuration; - const endPath = timingPath(id, "end"); - const ep = scalarChange(endPath, oldEnd, newEnd); - result.forward.push(ep.forward); - result.inverse.push(ep.inverse); - el.setAttribute("data-end", String(newEnd)); + writeClipTiming(el, timing); + const afterAttributes = { + start: el.getAttribute("data-start"), + duration: el.getAttribute("data-duration"), + end: el.getAttribute("data-end"), + trackIndex: el.getAttribute("data-track-index"), + layer: el.getAttribute("data-layer"), + }; + + const recordAttributeChange = ( + path: string, + before: string | null, + after: string | null, + numeric: boolean, + ) => { + if (before === after) return; + const toPatchValue = (value: string): string | number => { + if (!numeric) return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + }; + if (after === null) { + if (before === null) return; + const patch = scalarDelete(path, toPatchValue(before)); + result.forward.push(patch.forward); + result.inverse.push(patch.inverse); + return; } - } else if ( - (timing.duration !== undefined || timing.start !== undefined) && - newStart !== null && - newDuration !== null - ) { - const newEnd = newStart + newDuration; - // Store the computed end value directly (not the logical duration) so the inverse - // patch is self-contained and doesn't require data-start to be restored first. - const path = timingPath(id, "end"); - const p = scalarChange(path, oldEnd, newEnd); - result.forward.push(p.forward); - result.inverse.push(p.inverse); - el.setAttribute("data-end", String(newEnd)); - } - - if (timing.trackIndex !== undefined) { - const newTrack = timing.trackIndex; - const path = timingPath(id, "trackIndex"); - const p = scalarChange(path, oldTrack, newTrack); - result.forward.push(p.forward); - result.inverse.push(p.inverse); - el.setAttribute("data-track-index", String(newTrack)); - } + const oldValue = before === null ? null : toPatchValue(before); + const newValue = toPatchValue(after); + const patch = scalarChange(path, oldValue, newValue); + result.forward.push(patch.forward); + result.inverse.push(patch.inverse); + }; + + recordAttributeChange( + timingPath(id, "start"), + beforeAttributes.start, + afterAttributes.start, + true, + ); + recordAttributeChange( + timingPath(id, "duration"), + beforeAttributes.duration, + afterAttributes.duration, + true, + ); + recordAttributeChange(timingPath(id, "end"), beforeAttributes.end, afterAttributes.end, true); + recordAttributeChange( + timingPath(id, "trackIndex"), + beforeAttributes.trackIndex, + afterAttributes.trackIndex, + true, + ); + recordAttributeChange( + attrPath(id, "data-layer"), + beforeAttributes.layer, + afterAttributes.layer, + false, + ); // Sync GSAP tween positions: the GSAP script is the source of truth at play time — // the timeline rebuilds from it on every seek. Without this, DOM attribute edits diff --git a/packages/sdk/src/session.timings.test.ts b/packages/sdk/src/session.timings.test.ts index 39aa2574b6..3602e34f26 100644 --- a/packages/sdk/src/session.timings.test.ts +++ b/packages/sdk/src/session.timings.test.ts @@ -198,11 +198,9 @@ describe("getElementTimings — relative data-start references", () => { `.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); - // The cycle guard fires on the re-entrant call, contributing 0 for the - // self-reference's own start — the element's OWN duration (2) still applies on - // top of that, so enterAt=2, not 0. The guard's job is termination, not zeroing - // the whole chain. - expect(timings["hf-self"]).toMatchObject({ enterAt: 2, exitAt: 4 }); + // Cyclic references are invalid. The shared contract leaves the reference + // unresolved and this snapshot adapter applies its documented zero fallback. + expect(timings["hf-self"]).toMatchObject({ enterAt: 0, exitAt: 2 }); expect(Number.isFinite(timings["hf-self"]?.enterAt)).toBe(true); }); @@ -215,13 +213,10 @@ describe("getElementTimings — relative data-start references", () => { `.trim(); const comp = await openComposition(html); const timings = comp.getElementTimings(); - // Document order resolves hf-a first: it recurses into hf-b, which recurses back - // into hf-a — the guard fires there (returns 0), so hf-b's start = 0 + hf-a's - // duration (2) = 2. Back in hf-a's own resolution: start = hf-b's start (2) + - // hf-b's duration (3) = 5. Neither number is "correct" for a genuine cycle — - // the point is both are finite and the recursion terminates. - expect(timings["hf-a"]).toMatchObject({ enterAt: 5, exitAt: 7 }); - expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 }); + // Both invalid starts get the same deterministic fallback; results no + // longer depend on document traversal order. + expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 }); + expect(timings["hf-b"]).toMatchObject({ enterAt: 0, exitAt: 3 }); expect(Number.isFinite(timings["hf-a"]?.enterAt)).toBe(true); expect(Number.isFinite(timings["hf-b"]?.enterAt)).toBe(true); }); diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index d8cdc11280..ddbb9f4e62 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -38,7 +38,7 @@ import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; -import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; +import { readClipTiming, type ClipTiming } from "@hyperframes/core/composition-contract"; import { readDeclaredDefaults, validateVariables, @@ -340,63 +340,30 @@ class CompositionImpl implements Composition { this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null; } - // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" — - // parseStartExpression's grammar) into an absolute second, recursively against the - // referenced element's own resolved start + duration. A plain numeric data-start keeps - // the old parseFloat path unchanged — this only touches the case that used to silently - // resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the - // runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back - // on, so an unauthored sub-composition duration still resolves to 0, same as before. - // - // refId is always a BARE id (the reference grammar has no scope syntax), resolved via - // resolveScoped's bare-id rule: prefer the canonical top-level match, else document - // order. An element inside a sub-composition referencing a bare id that also exists at - // the top level resolves to the TOP-LEVEL one, not a same-scope sibling — this matches - // the runtime's own resolver (also a global, not scope-aware, lookup), so the two stay - // consistent, but it means a bare-id collision across scopes is a real footgun for - // authored content. - const startCache = new Map(); + // refId remains a bare global id, matching runtime resolution. The shared + // contract owns parsing, canonical duration preference, clamping, and + // legacy data-end compatibility; this adapter only supplies document lookup. + const timingCache = new Map(); const visiting = new Set(); - // Split out of resolveStart so its own branching stays low — this is the ONE - // path that recurses + calls resolveDuration, kept here so that's visible at a - // glance rather than buried inside resolveStart's try block. - const resolveReferenceStart = (refId: string, offset: number): number => { - const target = resolveScoped(this.parsed.document, refId); - if (!target) return 0; - return Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + offset); - }; - const resolveStart = (el: Element): number => { - const cached = startCache.get(el); - if (cached !== undefined) return cached; - if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop + const resolveTiming = (el: Element): ClipTiming => { + const cached = timingCache.get(el); + if (cached) return cached; + if (visiting.has(el)) return readClipTiming(el); visiting.add(el); - let resolved: number; try { - const startStr = el.getAttribute("data-start"); - const expr = parseStartExpression(startStr); - if (expr?.kind === "reference") { - resolved = resolveReferenceStart(expr.refId, expr.offset); - } else if (expr?.kind === "absolute") { - resolved = expr.value; - } else { - resolved = startStr !== null ? parseFloat(startStr) : 0; - } + const timing = readClipTiming(el, { + resolveReferenceEnd: (refId) => { + const target = resolveScoped(this.parsed.document, refId); + if (!target) return null; + const targetTiming = resolveTiming(target); + return targetTiming.end ?? targetTiming.start; + }, + }); + timingCache.set(el, timing); + return timing; } finally { visiting.delete(el); } - const finite = Number.isFinite(resolved) ? resolved : 0; - startCache.set(el, finite); - return finite; - }; - // Same preference as handleSetTiming: prefer data-duration, fall back to end - start. - const resolveDuration = (el: Element): number | null => { - const durationStr = el.getAttribute("data-duration"); - const durationAttr = durationStr !== null ? parseFloat(durationStr) : null; - if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr; - const endStr = el.getAttribute("data-end"); - const endAttr = endStr !== null ? parseFloat(endStr) : null; - if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el); - return null; }; const result: Record = {}; @@ -405,8 +372,9 @@ class CompositionImpl implements Composition { const domEl = resolveScoped(this.parsed.document, el.scopedId); if (!domEl) continue; - const enterAt = resolveStart(domEl); - const duration = resolveDuration(domEl); + const timing = resolveTiming(domEl); + const enterAt = timing.start ?? 0; + const duration = timing.duration; if (duration === null) continue; // no timing info — skip non-timed elements const exitAt = enterAt + duration; diff --git a/packages/studio-server/src/helpers/previewAdapter.ts b/packages/studio-server/src/helpers/previewAdapter.ts index e576a524cf..cd50d480a4 100644 --- a/packages/studio-server/src/helpers/previewAdapter.ts +++ b/packages/studio-server/src/helpers/previewAdapter.ts @@ -5,7 +5,7 @@ import { STUDIO_HEIGHT_PROP, STUDIO_MANUAL_EDIT_GESTURE_ATTR, } from "./draftMarkers.js"; -import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; +import { readClipTiming } from "@hyperframes/core/composition-contract"; export type DraftPayload = | { type: "move"; hfId: string; dx: number; dy: number } @@ -139,77 +139,38 @@ export function createPreviewAdapter( }, getElementTimings() { - // data-start can be a relative-reference expression ("intro", "intro + 2" — - // parseStartExpression's grammar), not just an absolute number. A raw - // parseFloat on it (the old behavior) silently resolves any reference to - // undefined. Resolve references recursively against the target's own - // resolved end (data-end, or data-start + data-duration when the target - // has no data-end) — this function never read data-duration before either, - // so a reference to a duration-authored (not end-authored) clip used to be - // unresolvable regardless of the parseFloat bug. - // - // This is the third copy of "resolve relative data-start" (runtime - // startResolver.ts; the SDK's own getElementTimings in session.ts; this - // one). The runtime version is substantially more complex (host offsets, - // media, live timelines) so a shared extraction isn't a straightforward - // win — this one and the SDK's stay hand-kept in sync instead. - const startCache = new Map(); + const timingCache = new Map(); const visiting = new Set(); - - const resolveEnd = (el: Element): number | undefined => { - const endAttr = el.getAttribute("data-end"); - if (endAttr !== null) { - const ev = parseFloat(endAttr); - if (Number.isFinite(ev)) return ev; - } - const durationAttr = el.getAttribute("data-duration"); - const dv = durationAttr !== null ? parseFloat(durationAttr) : NaN; - const sv = resolveStart(el); - if (Number.isFinite(dv) && sv !== undefined) return sv + dv; - return undefined; - }; - - // Split out of resolveStart so its own branching stays low — mirrors the - // SDK's getElementTimings resolver split (resolveReferenceStart). - const resolveReferenceStart = (refId: string, offset: number): number | undefined => { - const target = findById(refId); - const targetEnd = target ? resolveEnd(target) : undefined; - return targetEnd !== undefined ? Math.max(0, targetEnd + offset) : undefined; - }; - - const resolveStart = (el: Element): number | undefined => { - if (startCache.has(el)) return startCache.get(el); - if (visiting.has(el)) return undefined; // reference cycle — fail safe, don't loop + const resolveTiming = (el: Element): { start: number | null; end: number | null } => { + const cached = timingCache.get(el); + if (cached) return cached; + if (visiting.has(el)) return { start: null, end: null }; visiting.add(el); - let resolved: number | undefined; try { - const startStr = el.getAttribute("data-start"); - const expr = parseStartExpression(startStr); - if (expr?.kind === "reference") { - resolved = resolveReferenceStart(expr.refId, expr.offset); - } else if (expr?.kind === "absolute") { - resolved = expr.value; - } else { - // parseStartExpression returns null for empty/absent data-start, and - // also for a malformed grammar string (e.g. "3 abc" — a leading - // number followed by content the reference regex rejects). The - // parseFloat below only ever succeeds on that second, malformed - // case (a clean number or a clean reference already matched above). - const sv = startStr !== null ? parseFloat(startStr) : NaN; - resolved = Number.isFinite(sv) ? sv : undefined; - } + const timing = readClipTiming(el, { + defaultStart: null, + resolveReferenceEnd: (refId) => { + const target = findById(refId); + return target ? resolveTiming(target).end : null; + }, + }); + const resolved = { start: timing.start, end: timing.end }; + timingCache.set(el, resolved); + return resolved; } finally { visiting.delete(el); } - startCache.set(el, resolved); - return resolved; }; const result: Record = {}; for (const el of doc.querySelectorAll("[data-hf-id]")) { const hfId = el.getAttribute("data-hf-id"); if (!hfId) continue; - result[hfId] = { start: resolveStart(el), end: resolveEnd(el) }; + const timing = resolveTiming(el); + result[hfId] = { + start: timing.start ?? undefined, + end: timing.end ?? undefined, + }; } return result; }, diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index ef76ef618c..0c08e78e3b 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -3,6 +3,7 @@ import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; +import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract"; import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js"; export interface SourceMutationTarget { @@ -239,30 +240,16 @@ export interface SplitElementResult { function resolveElementTiming(el: Element): { start: number; duration: number; - usesDataEnd: boolean; } { - const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0; - const usesDataEnd = el.hasAttribute("data-end"); - const duration = usesDataEnd - ? parseFloat(el.getAttribute("data-end") ?? "") - start || 0 - : parseFloat(el.getAttribute("data-duration") ?? "0") || 0; - return { start, duration, usesDataEnd }; + const timing = readClipTiming(el); + return { start: timing.start ?? 0, duration: timing.duration ?? 0 }; } -function setElementDuration( - el: Element, - start: number, - duration: number, - usesDataEnd: boolean, -): void { - if (usesDataEnd) { - const endTime = String(Math.round((start + duration) * 1000) / 1000); - el.setAttribute("data-end", endTime); - el.removeAttribute("data-duration"); - } else { - el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000)); - el.removeAttribute("data-end"); - } +function setElementDuration(el: Element, start: number, duration: number): void { + writeClipTiming(el, { + start: Math.round(start * 1000) / 1000, + duration: Math.round(duration * 1000) / 1000, + }); } // fallow-ignore-next-line complexity @@ -278,7 +265,6 @@ export function splitElementInHtml( if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; const timing = resolveElementTiming(el); - const { usesDataEnd } = timing; let { start, duration } = timing; // GSAP-animated elements carry their timing in the script, not in data-* attrs, // so the source has no authored duration. Fall back to the store's (GSAP-derived) @@ -310,8 +296,7 @@ export function splitElementInHtml( // Descendants carry their own data-hf-id; leaving them duplicates the id of // every nested node (e.g. an inner ), so strip them on the clone too. for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id"); - clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); - setElementDuration(clone, splitTime, secondDuration, usesDataEnd); + setElementDuration(clone, splitTime, secondDuration); // Keep the "clip" class — the runtime uses it to control visibility // based on data-start/data-duration timing. @@ -340,8 +325,7 @@ export function splitElementInHtml( // Trim the original element's duration. A GSAP element had no data-start; stamp // it so the runtime windows the first half (visibility selects on [data-start]). - el.setAttribute("data-start", String(Math.round(start * 1000) / 1000)); - setElementDuration(el, start, firstDuration, usesDataEnd); + setElementDuration(el, start, firstDuration); // Insert clone after original if (el.nextSibling) { diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 47bc557fc0..85a1632da8 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -32,6 +32,21 @@ describe("splitElementInHtml", () => { expect(result.html).toContain('data-duration="4"'); }); + it("canonicalizes legacy timing attributes on both split halves", () => { + const legacy = source.replace( + 'data-start="1" data-duration="6"', + 'data-start="1" data-end="7" data-layer="3"', + ); + const result = splitElementInHtml(legacy, { id: "box" }, 3, "box-split"); + + expect(result.matched).toBe(true); + expect(result.html).not.toContain("data-end="); + expect(result.html).not.toContain("data-layer="); + expect(result.html.match(/data-track-index="3"/g)).toHaveLength(2); + expect(result.html).toContain('data-duration="2"'); + expect(result.html).toContain('data-duration="4"'); + }); + it("duplicates CSS rules for the new element ID", () => { const result = splitElementInHtml(source, { id: "box" }, 3, "box-split"); expect(result.html).toContain("#box-split"); diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index f4b9a60dc8..0136d6cd3f 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -50,9 +50,16 @@ export interface ClipManifestClip { } export interface ClipManifest { + protocolVersion?: number; + compositionContractVersion?: number; + capabilities?: readonly string[]; + fps?: { numerator: number; denominator: number }; + durationSeconds?: number; clips: ClipManifestClip[]; scenes: Array<{ id: string; label: string; start: number; duration: number }>; durationInFrames: number; + compositionWidth?: number; + compositionHeight?: number; } export type IframeWindow = Window & { diff --git a/packages/studio/src/player/lib/runtimeProtocol.test.ts b/packages/studio/src/player/lib/runtimeProtocol.test.ts index 3747266af0..21a6811695 100644 --- a/packages/studio/src/player/lib/runtimeProtocol.test.ts +++ b/packages/studio/src/player/lib/runtimeProtocol.test.ts @@ -14,7 +14,12 @@ describe("Studio runtime protocol", () => { type: "control", action: "seek", protocolVersion: 1, - capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"], + capabilities: [ + "seconds-time", + "rational-fps", + "seek-keep-playing", + "composition-manifest-v1", + ], fps: { numerator: 60, denominator: 1 }, timeSeconds: 1.25, }); diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 55dad9bf9a..1c7fa64815 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -10,6 +10,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "./playbackTypes"; +import { readClipTiming } from "@hyperframes/core/composition-contract"; import { resolveMediaElement, applyMediaMetadataFromElement, @@ -244,24 +245,20 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel if (node === rootComp) return; if (isTimelineIgnoredElement(node)) return; const el = node as HTMLElement; - const startStr = el.getAttribute("data-start"); - if (startStr == null) return; - const start = parseFloat(startStr); - if (isNaN(start)) return; + const timing = readClipTiming(el); + const start = timing.start; + if (start == null) return; if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return; const tagLower = el.tagName.toLowerCase(); - let dur = 0; - const durStr = el.getAttribute("data-duration"); - if (durStr != null) dur = parseFloat(durStr); - if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start); + let dur = timing.duration ?? 0; + if (dur <= 0) dur = Math.max(0, rootDuration - start); if (Number.isFinite(rootDuration) && rootDuration > 0) { dur = Math.min(dur, Math.max(0, rootDuration - start)); } if (!Number.isFinite(dur) || dur <= 0) return; - const trackStr = el.getAttribute("data-track-index"); - const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++; + const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex; // fallow-ignore-next-line code-duplication const compId = el.getAttribute("data-composition-id"); const selector = getTimelineElementSelector(el); @@ -288,7 +285,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel tag: tagLower, start, duration: dur, - track: isNaN(track) ? 0 : track, + track, domId: el.id || undefined, hfId: el.getAttribute("data-hf-id") || undefined, selector, diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index f00700f458..7885ccbfc8 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { IframeWindow } from "./playbackTypes"; +import { readClipTiming } from "@hyperframes/core/composition-contract"; import { getTimelineElementSelector, getTimelineElementSourceFile, @@ -38,6 +39,9 @@ export function normalizePreviewViewport(doc: Document, win: Window): void { win.scrollTo({ top: 0, left: 0, behavior: "auto" }); } +// Legacy recovery retained until versioned composition manifests complete +// their compatibility soak across published CDN runtimes. +// fallow-ignore-next-line complexity export function autoHealMissingCompositionIds(doc: Document): void { const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi; const referencedIds = new Set(); @@ -258,6 +262,141 @@ export function stopScrubPreviewAudio(): void { // Enrich missing compositions from DOM // --------------------------------------------------------------------------- +function timelineDuration(iframeWin: IframeWindow, compositionId: string): number { + return ( + ( + iframeWin.__timelines?.[compositionId] as { duration?: () => number } | undefined + )?.duration?.() ?? 0 + ); +} + +function createTimedElementLookup(doc: Document): Map { + const timedById = new Map(); + for (const timed of doc.querySelectorAll("[data-start]")) { + for (const id of [ + timed.id, + timed.getAttribute("data-hf-id"), + timed.getAttribute("data-composition-id"), + ]) { + if (id) timedById.set(id, timed); + } + } + return timedById; +} + +function createReferenceEndResolver( + timedById: ReadonlyMap, + iframeWin: IframeWindow, +): (refId: string, visiting: ReadonlySet) => number | null { + const resolveEnd = (refId: string, visiting: ReadonlySet): number | null => { + if (visiting.has(refId)) return null; + const referenced = timedById.get(refId); + if (!referenced) return null; + const next = new Set(visiting).add(refId); + const timing = readClipTiming(referenced, { + resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next), + }); + if (timing.end != null) return timing.end; + const compositionId = referenced.getAttribute("data-composition-id"); + const duration = compositionId ? timelineDuration(iframeWin, compositionId) : 0; + return timing.start == null || duration <= 0 ? null : timing.start + duration; + }; + return resolveEnd; +} + +function clampCompositionWindow( + start: number, + duration: number, + rootDuration: number, +): { start: number; duration: number } | null { + if (!Number.isFinite(duration) || duration <= 0) return null; + const safeStart = Number.isFinite(start) ? start : 0; + if (!Number.isFinite(rootDuration) || rootDuration <= 0) { + return { start: safeStart, duration }; + } + if (safeStart >= rootDuration) return null; + const clamped = Math.min(duration, Math.max(0, rootDuration - safeStart)); + return clamped > 0 ? { start: safeStart, duration: clamped } : null; +} + +function nonEmpty(value: string, fallback: string): string { + return value || fallback; +} + +function optionalNonEmpty(value: string | null): string | undefined { + return value || undefined; +} + +function attachCompositionSource( + entry: TimelineElement, + element: HTMLElement, + compositionSrc: string | null, +): TimelineElement { + if (compositionSrc) return { ...entry, compositionSrc }; + const innerVideo = element.querySelector("video[src]"); + if (!innerVideo) return entry; + return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" }; +} + +function buildMissingCompositionEntry(params: { + doc: Document; + iframeWin: IframeWindow; + element: HTMLElement; + compositionId: string; + rootDuration: number; + fallbackIndex: number; + resolveEnd: (refId: string, visiting: ReadonlySet) => number | null; +}): TimelineElement | null { + const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } = + params; + const timing = readClipTiming(element, { + resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])), + }); + const window = clampCompositionWindow( + timing.start ?? 0, + timing.duration ?? timelineDuration(iframeWin, compositionId), + rootDuration, + ); + if (!window) return null; + + const preferredId = nonEmpty(element.id, compositionId); + const compositionSrc = + element.getAttribute("data-composition-src") ?? element.getAttribute("data-composition-file"); + const selector = getTimelineElementSelector(element); + const sourceFile = getTimelineElementSourceFile(element); + const selectorIndex = getTimelineElementSelectorIndex(doc, element, selector); + const label = getTimelineElementDisplayLabel({ + id: preferredId, + label: element.getAttribute("data-timeline-label") ?? element.getAttribute("data-label"), + tag: element.tagName, + }); + const identity = buildTimelineElementIdentity({ + preferredId, + label, + fallbackIndex, + domId: optionalNonEmpty(element.id), + selector, + selectorIndex, + sourceFile, + }); + const entry: TimelineElement = { + id: identity.id, + label, + key: identity.key, + tag: element.tagName.toLowerCase(), + start: window.start, + duration: window.duration, + track: timing.trackIndex, + domId: optionalNonEmpty(element.id), + hfId: optionalNonEmpty(element.getAttribute("data-hf-id")), + selector, + selectorIndex, + sourceFile, + zIndex: readTimelineElementZIndex(element), + }; + return attachCompositionSource(entry, element, compositionSrc); +} + /** * Scan the iframe DOM for composition hosts missing from the current * timeline elements and add them. The CDN runtime often fails to resolve @@ -279,117 +418,24 @@ export function buildMissingCompositionElements( const hosts = doc.querySelectorAll("[data-composition-id][data-start]"); const missing: TimelineElement[] = []; - hosts.forEach((host) => { + const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin); + + for (const host of hosts) { const el = host as HTMLElement; const compId = el.getAttribute("data-composition-id"); - if (!compId || compId === rootCompId) return; - if (existingIds.has(el.id) || existingIds.has(compId)) return; - - // Resolve start: numeric or element-reference - const startAttr = el.getAttribute("data-start") ?? "0"; - let start = parseFloat(startAttr); - if (isNaN(start)) { - const ref = - doc.getElementById(startAttr) || - doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`); - if (ref) { - const refStartAttr = ref.getAttribute("data-start") ?? "0"; - let refStart = parseFloat(refStartAttr); - // Recursively resolve one level of reference for the ref's own start - if (isNaN(refStart)) { - const refRef = - doc.getElementById(refStartAttr) || - doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`); - const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0; - const rrCompId = refRef?.getAttribute("data-composition-id"); - const rrDur = - parseFloat(refRef?.getAttribute("data-duration") ?? "") || - (rrCompId - ? (( - iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined - )?.duration?.() ?? 0) - : 0); - refStart = rrStart + rrDur; - } - const refCompId = ref.getAttribute("data-composition-id"); - const refDur = - parseFloat(ref.getAttribute("data-duration") ?? "") || - (refCompId - ? (( - iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined - )?.duration?.() ?? 0) - : 0); - start = refStart + refDur; - } else { - start = 0; - } - } - - // Resolve duration from data-duration or GSAP timeline - let dur = parseFloat(el.getAttribute("data-duration") ?? ""); - if (isNaN(dur) || dur <= 0) { - dur = - ( - iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined - )?.duration?.() ?? 0; - } - if (!Number.isFinite(dur) || dur <= 0) return; - if (!Number.isFinite(start)) start = 0; - if (Number.isFinite(rootDuration) && rootDuration > 0) { - if (start >= rootDuration) return; - dur = Math.min(dur, Math.max(0, rootDuration - start)); - if (dur <= 0) return; - } - - const trackStr = el.getAttribute("data-track-index"); - const track = trackStr != null ? parseInt(trackStr, 10) : 0; - // fallow-ignore-next-line code-duplication - const compSrc = - el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file"); - const selector = getTimelineElementSelector(el); - const sourceFile = getTimelineElementSourceFile(el); - const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector); - const label = getTimelineElementDisplayLabel({ - id: el.id || compId || null, - label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"), - tag: el.tagName, - }); - const identity = buildTimelineElementIdentity({ - preferredId: el.id || compId || null, - label, + if (!compId || compId === rootCompId) continue; + if (existingIds.has(el.id) || existingIds.has(compId)) continue; + const entry = buildMissingCompositionEntry({ + doc, + iframeWin, + element: el, + compositionId: compId, + rootDuration, fallbackIndex: missing.length, - domId: el.id || undefined, - selector, - selectorIndex, - sourceFile, + resolveEnd, }); - const entry: TimelineElement = { - id: identity.id, - label, - key: identity.key, - tag: el.tagName.toLowerCase(), - start, - duration: dur, - track: isNaN(track) ? 0 : track, - domId: el.id || undefined, - hfId: el.getAttribute("data-hf-id") || undefined, - selector, - selectorIndex, - sourceFile, - zIndex: readTimelineElementZIndex(el), - }; - if (compSrc) { - entry.compositionSrc = compSrc; - } else { - // Inline composition — expose inner video for thumbnails - const innerVideo = el.querySelector("video[src]"); - if (innerVideo) { - entry.src = innerVideo.getAttribute("src") || undefined; - entry.tag = "video"; - } - } - missing.push(entry); - }); + if (entry) missing.push(entry); + } // Patch existing elements that are missing compositionSrc let patched = false; diff --git a/scripts/verify-packed-manifests.mjs b/scripts/verify-packed-manifests.mjs index 5e63b782d4..d5a0416547 100644 --- a/scripts/verify-packed-manifests.mjs +++ b/scripts/verify-packed-manifests.mjs @@ -318,7 +318,21 @@ function writeConsumerFixture(packDir, packedWorkspaces) { writeFileSync( join(fixtureDir, "package.json"), JSON.stringify( - { name: "packed-consumer", private: true, type: "module", dependencies }, + { + name: "packed-consumer", + private: true, + type: "module", + dependencies, + // Force transitive workspace edges onto the tarballs under test. Without + // this, Bun may install the last registry release beneath a packed + // workspace and accidentally validate mixed-version internals. + overrides: Object.fromEntries( + packedWorkspaces.map(({ filename, packedPackage }) => [ + packedPackage.name, + `file:${filename}`, + ]), + ), + }, null, 2, ),