feat(sdk): export getRootElements/isNewHostBoundary/bareId, fix relative data-start

Closes gaps surfaced by pacific#30298 (hyperframes layer panel), where consumer
code had to hand-roll fixes for things the SDK/core already solve or nearly solve:

- getRootElements(): getElements() flattens the tree, so every descendant also
  appears as its own top-level entry. buildRoots() already computes true roots
  internally; this exposes it directly instead of making consumers re-derive
  roots by filtering out descendant ids.
- Export isNewHostBoundary + bareId from @hyperframes/sdk: both already existed
  internally (engine/model.ts) but weren't exported, so consumers were
  duplicating sub-composition-boundary detection and scoped-id-to-DOM-leaf
  conversion by hand.
- Export stripEmbeddedRuntimeScripts + RUNTIME_BOOTSTRAP_ATTR from
  @hyperframes/core, and wire serialize({ stripRuntime: true }) on the SDK
  session: a proper tokenizing implementation already existed in
  compiler/htmlDocument.ts (handles more runtime-script marker variants than a
  naive regex), just never exported. The SDK itself imports these via narrow
  subpaths (./runtime/start-expression, ./compiler/html-document) rather than
  the wide ./compiler barrel, matching the SDK's existing import convention and
  avoiding pulling Node-only compiler code (fs/path) into browser bundles.
- Fix getElementTimings(): data-start can be a relative-reference expression
  ("intro", "intro + 2" — see parseStartExpression's grammar), not just an
  absolute number. The old code did a raw parseFloat() on it, which silently
  resolved any reference expression to 0. Now resolves references recursively
  against the target element's own resolved start + duration, Node-safe (no
  live GSAP timeline needed for this case).

14 new tests (session.timings.test.ts, session.subcomp.test.ts). Full sdk
suite: 417/417 passing. Full workspace build (incl. studio) verified clean.
This commit is contained in:
Vance Ingalls
2026-07-08 21:29:52 -07:00
parent 0ac000181e
commit c1b8815cb2
8 changed files with 263 additions and 23 deletions
+20
View File
@@ -95,6 +95,18 @@
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts"
},
"./runtime/start-expression": {
"bun": "./src/runtime/startExpression.ts",
"node": "./dist/runtime/startExpression.js",
"import": "./src/runtime/startExpression.ts",
"types": "./src/runtime/startExpression.ts"
},
"./compiler/html-document": {
"bun": "./src/compiler/htmlDocument.ts",
"node": "./dist/compiler/htmlDocument.js",
"import": "./src/compiler/htmlDocument.ts",
"types": "./src/compiler/htmlDocument.ts"
},
"./runtime/position-edits": {
"bun": "./src/runtime/positionEdits.ts",
"node": "./dist/runtime/positionEdits.js",
@@ -275,6 +287,14 @@
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
"./runtime/start-expression": {
"import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts"
},
"./compiler/html-document": {
"import": "./dist/compiler/htmlDocument.js",
"types": "./dist/compiler/htmlDocument.d.ts"
},
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
+1
View File
@@ -158,6 +158,7 @@ export {
type SubCompositionValidity,
type SubCompositionValidityReason,
} from "./compiler/subCompositionValidity";
export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument";
export { queryByAttr } from "./utils/cssSelector";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
+11
View File
@@ -134,6 +134,17 @@ export function resolveScoped(document: Document, id: string): Element | null {
return context as Element;
}
/**
* Bare leaf id from a scoped hf-id ("hf-HOST/hf-LEAF" → "hf-LEAF"; a bare id
* passes through unchanged). The live DOM's `data-hf-id` attribute never
* carries the host-chain prefix, so a consumer holding a scopedId (from
* getElements()/getElement()) needs this to query the rendered DOM directly.
*/
export function bareId(scopedId: string): string {
const parts = scopedId.split("/");
return parts[parts.length - 1] ?? scopedId;
}
/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
+2
View File
@@ -26,6 +26,8 @@ export { UnsupportedOpError } from "./engine/mutate.js";
export { buildDocument, buildRoots, flatElements } from "./document.js";
export { isNewHostBoundary, bareId } from "./engine/model.js";
export { openComposition } from "./session.js";
export type { OpenCompositionOptions } from "./session.js";
+118 -1
View File
@@ -13,7 +13,7 @@
import { describe, it, expect } from "vitest";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { resolveScoped, findById } from "./engine/model.js";
import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/model.js";
import { parseMutable } from "./engine/model.js";
import { buildRoots, flatElements } from "./document.js";
import { openComposition } from "./session.js";
@@ -531,3 +531,120 @@ describe("scopedId stability across serialize/re-parse", () => {
expect(ids1).toEqual(ids2);
});
});
// ─── 7. isNewHostBoundary ──────────────────────────────────────────────────────
describe("isNewHostBoundary", () => {
it("is true for a host with no ancestor dcf (top-level sub-comp host)", () => {
const doc = makeDoc(
inlinedHtml(`<div data-hf-id="hf-host" data-composition-file="sub.html"></div>`),
) as unknown as Document;
const host = doc.querySelector('[data-hf-id="hf-host"]') as unknown as Element;
expect(isNewHostBoundary(host)).toBe(true);
});
it("is false for an element with no data-composition-file at all", () => {
const doc = makeDoc(inlinedHtml(`<div data-hf-id="hf-plain"></div>`)) as unknown as Document;
const el = doc.querySelector('[data-hf-id="hf-plain"]') as unknown as Element;
expect(isNewHostBoundary(el)).toBe(false);
});
it("is false for the outerHTML innerRoot (same dcf value as its host parent)", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-inner" data-composition-file="sub.html"></div>
</div>
`),
) as unknown as Document;
const inner = doc.querySelector('[data-hf-id="hf-inner"]') as unknown as Element;
expect(isNewHostBoundary(inner)).toBe(false);
});
it("is true for a nested host with a DIFFERENT dcf from its parent", () => {
const doc = makeDoc(
inlinedHtml(`
<div data-hf-id="hf-outer" data-composition-file="outer.html">
<div data-hf-id="hf-inner-host" data-composition-file="inner.html"></div>
</div>
`),
) as unknown as Document;
const innerHost = doc.querySelector('[data-hf-id="hf-inner-host"]') as unknown as Element;
expect(isNewHostBoundary(innerHost)).toBe(true);
});
});
// ─── 8. bareId ──────────────────────────────────────────────────────────────────
describe("bareId", () => {
it("returns the leaf segment of a scoped id", () => {
expect(bareId("hf-host/hf-leaf")).toBe("hf-leaf");
});
it("returns a deeply nested id's leaf segment", () => {
expect(bareId("hf-a/hf-b/hf-c")).toBe("hf-c");
});
it("passes a bare id through unchanged", () => {
expect(bareId("hf-solo")).toBe("hf-solo");
});
});
// ─── 9. getRootElements — no descendant duplication ────────────────────────────
describe("getRootElements", () => {
it("excludes descendants that getElements() also lists as top-level entries", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-panel">
<h1 data-hf-id="hf-title">Title</h1>
</div>
<p data-hf-id="hf-solo">solo</p>
`);
const comp = await openComposition(html);
// getElements() is flat: hf-title appears once nested under hf-panel AND
// once again as its own top-level entry.
const flatIds = comp.getElements().map((e) => e.id);
expect(flatIds).toContain("hf-title");
expect(flatIds).toContain("hf-panel");
// getRootElements() only returns true roots — hf-title is not one, since
// it's hf-panel's descendant.
const rootIds = comp.getRootElements().map((e) => e.id);
expect(rootIds).toEqual(["hf-panel", "hf-solo"]);
expect(comp.getRootElements().find((e) => e.id === "hf-panel")?.children[0]?.id).toBe(
"hf-title",
);
});
it("treats a sub-composition host as a root even though it has descendants", async () => {
const html = inlinedHtml(`
<div data-hf-id="hf-host" data-composition-file="sub.html">
<p data-hf-id="hf-leaf">inside</p>
</div>
`);
const comp = await openComposition(html);
expect(comp.getRootElements().map((e) => e.id)).toEqual(["hf-host"]);
});
});
// ─── 10. serialize({ stripRuntime }) ───────────────────────────────────────────
describe("serialize({ stripRuntime })", () => {
const RUNTIME_SCRIPT =
'<script data-hyperframes-preview-runtime="1" src="https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"></script>';
it("keeps the embedded runtime script by default", async () => {
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
const comp = await openComposition(html);
expect(comp.serialize()).toContain("hyperframe.runtime");
});
it("strips the embedded runtime script when stripRuntime is true", async () => {
const html = `<!DOCTYPE html><html><head>${RUNTIME_SCRIPT}</head><body><div data-hf-id="hf-a"></div></body></html>`;
const comp = await openComposition(html);
const out = comp.serialize({ stripRuntime: true });
expect(out).not.toContain("hyperframe.runtime");
expect(out).toContain('data-hf-id="hf-a"');
});
});
+40
View File
@@ -113,6 +113,46 @@ describe("getElementTimings — GSAP labels", () => {
});
});
// ─── getElementTimings — relative data-start references ──────────────────────
/** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */
const RELATIVE_START_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px" data-duration="20">
<h1 data-hf-id="hf-intro" data-start="1" data-duration="3">Intro</h1>
<p data-hf-id="hf-outro" data-start="hf-intro + 2" data-duration="4">Outro</p>
<p data-hf-id="hf-right-after" data-start="hf-intro" data-duration="1">Right after</p>
</div>
`.trim();
describe("getElementTimings — relative data-start references", () => {
it("resolves 'ref + offset' against the referenced element's resolved end", async () => {
const comp = await openComposition(RELATIVE_START_HTML);
const timings = comp.getElementTimings();
// hf-intro: enterAt=1, exitAt=4
expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 });
// hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6
expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 });
});
it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => {
const comp = await openComposition(RELATIVE_START_HTML);
const timings = comp.getElementTimings();
expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 });
});
it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-orphan" data-start="hf-nonexistent + 5" data-duration="2"></p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 });
});
});
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
describe("setElementTiming", () => {
+69 -22
View File
@@ -36,6 +36,8 @@ import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScript, resolveScoped } 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 { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
@@ -174,33 +176,61 @@ class CompositionImpl implements Composition {
this._gsapLabelCache = script ? { script, 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.
const startCache = new Map<Element, number>();
const visiting = new Set<Element>();
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
visiting.add(el);
let resolved: number;
try {
const startStr = el.getAttribute("data-start");
const expr = parseStartExpression(startStr);
if (expr?.kind === "reference") {
const target = resolveScoped(this.parsed.document, expr.refId);
resolved = target
? Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + expr.offset)
: 0;
} else {
resolved = startStr !== null ? parseFloat(startStr) : 0;
}
} 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<HfId, ElementTimingSnapshot> = {};
const elements = this.getElements();
for (const el of elements) {
const domEl = resolveScoped(this.parsed.document, el.scopedId);
if (!domEl) continue;
const startStr = domEl.getAttribute("data-start");
const endStr = domEl.getAttribute("data-end");
const durationStr = domEl.getAttribute("data-duration");
const enterAt = resolveStart(domEl);
const duration = resolveDuration(domEl);
if (duration === null) continue; // no timing info — skip non-timed elements
// Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
const start = startStr !== null ? parseFloat(startStr) : 0;
const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
const endAttr = endStr !== null ? parseFloat(endStr) : null;
let duration: number;
if (durationAttr !== null && Number.isFinite(durationAttr)) {
duration = durationAttr;
} else if (endAttr !== null && Number.isFinite(endAttr)) {
duration = endAttr - start;
} else {
// No timing info — skip non-timed elements.
continue;
}
const enterAt = Number.isFinite(start) ? start : 0;
const exitAt = enterAt + (Number.isFinite(duration) ? duration : 0);
const exitAt = enterAt + duration;
// Labels whose position falls within [enterAt, exitAt] (end-inclusive: a
// label exactly at exitAt is treated as within the element's window).
@@ -307,6 +337,18 @@ class CompositionImpl implements Composition {
return [...this.elementsCache];
}
/**
* Top-level elements only (each still carrying its full descendant subtree via
* `.children`) — unlike `getElements()`, no element appears twice. Consumers building a
* tree view (a layer panel) want this, not `getElements()`: that method's flat list
* includes every descendant a second time as its own top-level entry, since each
* snapshot in it still carries its children. `buildRoots` already computes true roots
* internally for `getElements()` to flatten — this just returns them unflattened.
*/
getRootElements(): ElementSnapshot[] {
return buildRoots(this.parsed.document);
}
getElement(id: HfId): ElementSnapshot | null {
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
@@ -567,8 +609,13 @@ class CompositionImpl implements Composition {
// ── Serialization ────────────────────────────────────────────────────────────
serialize(): string {
return serializeDocument(this.parsed);
serialize(opts?: { stripRuntime?: boolean }): string {
const html = serializeDocument(this.parsed);
// Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own
// HTML. A host driving its own clock (an editing iframe) must not let that runtime
// self-init — it races the host's first seek and resets the timeline to t=0. Opt-in
// (default false) since a host playing the composition normally wants the runtime.
return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html;
}
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
+2
View File
@@ -461,6 +461,8 @@ export interface Composition {
// ── Query API (F1) ─────────────────────────────────────────────────────────
getElements(): ElementSnapshot[];
/** Top-level elements only, each carrying its full subtree — no id appears twice. */
getRootElements(): ElementSnapshot[];
getElement(id: HfId): ElementSnapshot | null;
find(query: FindQuery): string[];
/**