mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user