Merge pull request #2092 from heygen-com/07-08-feat_sdk_export_getrootelements_isnewhostboundary_bareid_fix_relative_data-start

feat(sdk): export getRootElements/isNewHostBoundary/bareId, fix relative data-start
This commit is contained in:
Vance Ingalls
2026-07-08 22:12:41 -07:00
committed by GitHub
8 changed files with 377 additions and 24 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";
+14
View File
@@ -134,6 +134,20 @@ 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("/");
// split() always returns >=1 element, so this never actually falls through at
// runtime — the fallback exists to satisfy noUncheckedIndexedAccess, not as a
// reachable safety net.
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";
+126 -1
View File
@@ -13,7 +13,8 @@
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 { RUNTIME_BOOTSTRAP_ATTR } from "@hyperframes/core";
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 +532,127 @@ 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"');
});
it("re-exports RUNTIME_BOOTSTRAP_ATTR from @hyperframes/core, matching the marker generators stamp", async () => {
expect(RUNTIME_BOOTSTRAP_ATTR).toBe("data-hyperframes-preview-runtime");
// The fixture's marker attribute above is authored by hand — confirm it's not
// drifted from the real constant a generator would actually stamp.
expect(RUNTIME_SCRIPT).toContain(RUNTIME_BOOTSTRAP_ATTR);
});
});
+117
View File
@@ -113,6 +113,123 @@ 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 });
});
it("resolves a chained reference (A -> B -> C) through the recursive resolver", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<h1 data-hf-id="hf-a" data-start="0" data-duration="2">A</h1>
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3">B</p>
<p data-hf-id="hf-c" data-start="hf-b + 1" data-duration="1">C</p>
</div>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
expect(timings["hf-a"]).toMatchObject({ enterAt: 0, exitAt: 2 });
// hf-b: "hf-a" (no offset) = a's exitAt (2)
expect(timings["hf-b"]).toMatchObject({ enterAt: 2, exitAt: 5 });
// hf-c: "hf-b + 1" = b's exitAt (5) + 1 = 6
expect(timings["hf-c"]).toMatchObject({ enterAt: 6, exitAt: 7 });
});
it("terminates (not an infinite loop) on a direct self-reference", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-self" data-start="hf-self" data-duration="2"></p>
</div>
`.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 });
expect(Number.isFinite(timings["hf-self"]?.enterAt)).toBe(true);
});
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", async () => {
const html = `
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<p data-hf-id="hf-a" data-start="hf-b" data-duration="2"></p>
<p data-hf-id="hf-b" data-start="hf-a" data-duration="3"></p>
</div>
`.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 });
expect(Number.isFinite(timings["hf-a"]?.enterAt)).toBe(true);
expect(Number.isFinite(timings["hf-b"]?.enterAt)).toBe(true);
});
it("resolves a colliding bare id to the TOP-LEVEL match, not a same-scope sibling", async () => {
// Bare ids have no scope syntax — resolveScoped's bare-id rule prefers the
// canonical top-level match when one exists, same as the runtime's own (also
// global, not scope-aware) resolver. Both the outer document AND the sub-comp
// author an element with the SAME bare id "hf-intro" — a genuine collision.
const html = `
<!DOCTYPE html><html><body>
<h1 data-hf-id="hf-intro" data-start="0" data-duration="10">Outer intro</h1>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<h1 data-hf-id="hf-intro" data-start="0" data-duration="1">Inner intro (same bare id)</h1>
<p data-hf-id="hf-outro" data-start="hf-intro + 1" data-duration="1">Inner outro</p>
</div>
</body></html>
`.trim();
const comp = await openComposition(html);
const timings = comp.getElementTimings();
// "hf-intro + 1", authored on an element INSIDE the sub-comp, still resolves
// against the OUTER hf-intro (exitAt=10) — 10 + 1 = 11 — not the same-scope
// inner hf-intro (exitAt=1, which would give 2). This pins current behavior;
// it is a real authoring footgun, not a claim that it's the ideal semantics.
expect(timings["hf-host/hf-outro"]).toMatchObject({ enterAt: 11, exitAt: 12 });
});
});
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
describe("setElementTiming", () => {
+93 -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";
@@ -75,6 +77,8 @@ class CompositionImpl implements Composition {
/** Lazily-built element snapshot, invalidated on every mutation. */
private elementsCache: ElementSnapshot[] | null = null;
/** Lazily-built root snapshot (getRootElements), invalidated alongside elementsCache. */
private rootsCache: ElementSnapshot[] | null = null;
private currentSelection: string[] = [];
@@ -174,33 +178,76 @@ 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.
//
// 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<Element, number>();
const visiting = new Set<Element>();
// 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
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;
}
} 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 +354,21 @@ 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.
* Cached like elementsCache — a layer panel calling this every render tick shouldn't
* repay the DOM walk each time.
*/
getRootElements(): ElementSnapshot[] {
this.rootsCache ??= buildRoots(this.parsed.document);
return [...this.rootsCache];
}
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
@@ -401,6 +463,7 @@ class CompositionImpl implements Composition {
}
this.elementsCache = null;
this.rootsCache = null;
// Update override-set from forward patches
for (const p of forward) {
@@ -505,6 +568,7 @@ class CompositionImpl implements Composition {
applyPatchesToDocument(this.parsed, [...this.batchInverse].reverse());
this.overrides = { ...this.batchOverridesSnapshot };
this.elementsCache = null;
this.rootsCache = null;
}
this.resetBatchState();
// Empty no-op batch: fire changeHandlers (parity with dispatch)
@@ -567,8 +631,14 @@ 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. Any host driving its own clock (not just an editing iframe — anything that
// owns seeking/playback itself) 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 ──────────────────────────────────────────────────
@@ -587,6 +657,7 @@ class CompositionImpl implements Composition {
// Emit a patch event so subscribers stay in sync.
applyPatchesToDocument(this.parsed, patches);
this.elementsCache = null;
this.rootsCache = null;
// Update override-set
for (const p of patches) {
+4 -1
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[];
/**
@@ -498,7 +500,8 @@ export interface Composition {
on(event: "persist:error", handler: (event: PersistErrorEvent) => void): () => void;
// ── Serialization ──────────────────────────────────────────────────────────
serialize(): string;
/** stripRuntime removes an embedded preview-runtime script — for a host driving its own clock. */
serialize(opts?: { stripRuntime?: boolean }): string;
// ── T3 embedded-mode extras ────────────────────────────────────────────────
/** Current override-set — serialize for host storage */