Merge pull request #2502 from heygen-com/task4-start-resolver-export

feat(core): export runtime start resolver with documentRef parameter
This commit is contained in:
Vance Ingalls
2026-07-17 00:47:42 -07:00
committed by GitHub
4 changed files with 70 additions and 4 deletions
+10
View File
@@ -113,6 +113,12 @@
"import": "./src/runtime/startExpression.ts",
"types": "./src/runtime/startExpression.ts"
},
"./runtime/start-resolver": {
"bun": "./src/runtime/startResolver.ts",
"node": "./dist/runtime/startResolver.js",
"import": "./src/runtime/startResolver.ts",
"types": "./src/runtime/startResolver.ts"
},
"./runtime/protocol": {
"bun": "./src/runtime/protocol.ts",
"node": "./dist/runtime/protocol.js",
@@ -329,6 +335,10 @@
"import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts"
},
"./runtime/start-resolver": {
"import": "./dist/runtime/startResolver.js",
"types": "./dist/runtime/startResolver.d.ts"
},
"./runtime/protocol": {
"import": "./dist/runtime/protocol.js",
"types": "./dist/runtime/protocol.d.ts"
+7
View File
@@ -258,6 +258,13 @@ export {
type ClipTimingDiagnosticCode,
type ClipTimingUpdate,
} from "./compositionContract.js";
// Also exposed via the ./runtime/start-resolver subpath. This root re-export
// additionally makes tsc EMIT dist/runtime/startResolver.js: src/runtime is
// excluded from the tsconfig include set, so runtime files only reach dist
// when an included module imports them — without this line the subpath's
// publishConfig entry points at a file the pack doesn't contain
// (verify:packed-manifests catches exactly that).
export { createRuntimeStartTimeResolver } from "./runtime/startResolver.js";
// Variable validation (CLI / tooling-side)
export {
@@ -399,3 +399,35 @@ describe("createRuntimeStartTimeResolver", () => {
});
});
});
describe("documentRef", () => {
it("resolves references against a supplied document instead of the global one", () => {
const doc = document.implementation.createHTMLDocument("t");
const intro = doc.createElement("div");
intro.id = "intro";
intro.setAttribute("data-start", "0");
intro.setAttribute("data-duration", "4");
doc.body.appendChild(intro);
const outro = doc.createElement("div");
outro.setAttribute("data-start", "intro + 2");
doc.body.appendChild(outro);
const resolver = createRuntimeStartTimeResolver({ documentRef: doc });
// intro ends at 0 + 4; outro starts 2s after → 6.
expect(resolver.resolveStartForElement(outro)).toBe(6);
});
it("defaults to the global document when documentRef is omitted", () => {
const el = document.createElement("div");
el.id = "ref-a";
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", "2");
document.body.appendChild(el);
const dependent = document.createElement("div");
dependent.setAttribute("data-start", "ref-a");
document.body.appendChild(dependent);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveStartForElement(dependent)).toBe(2);
});
});
+21 -4
View File
@@ -25,25 +25,40 @@ function parseAuthoredEndAttr(element: Element): number | null {
export function createRuntimeStartTimeResolver(params: {
timelineRegistry?: Record<string, RuntimeTimelineLike | undefined>;
includeAuthoredTimingAttrs?: boolean;
/**
* The document that reference lookups (`data-start="intro + 2"`) resolve
* against. Defaults to the global `document` — the runtime bundle's own
* realm. Hosts driving a composition in an IFRAME must pass that iframe's
* document, or every reference silently resolves against the host page.
*/
documentRef?: Document;
}): {
resolveStartForElement: (element: Element, fallback?: number) => number;
resolveDurationForElement: (element: Element) => number | null;
} {
const timelineRegistry = params.timelineRegistry ?? {};
const includeAuthoredTimingAttrs = params.includeAuthoredTimingAttrs ?? false;
const doc = params.documentRef ?? document;
const startCache = new WeakMap<Element, number | null>();
const durationCache = new WeakMap<Element, number | null>();
const visiting = new Set<Element>();
const findReferenceTarget = (refId: string): Element | null => {
const byId = document.getElementById(refId);
const byId = doc.getElementById(refId);
if (byId) return byId;
return (
(document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ??
null
(doc.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ?? null
);
};
// Realm-safe: an iframe document's media elements are instances of THAT
// frame's HTMLMediaElement, never this module's global one.
const isMediaElement = (el: Element): el is HTMLMediaElement => {
const RealmMedia = el.ownerDocument.defaultView?.HTMLMediaElement;
if (RealmMedia) return el instanceof RealmMedia;
return typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement;
};
const resolveDurationForElement = (element: Element): number | null => {
const cached = durationCache.get(element);
if (cached !== undefined) return cached;
@@ -66,7 +81,7 @@ export function createRuntimeStartTimeResolver(params: {
}
}
}
if ((resolved == null || resolved <= 0) && element instanceof HTMLMediaElement) {
if ((resolved == null || resolved <= 0) && isMediaElement(element)) {
const playbackStart =
parseNumeric(element.getAttribute("data-playback-start")) ??
parseNumeric(element.getAttribute("data-media-start")) ??
@@ -181,3 +196,5 @@ export function createRuntimeStartTimeResolver(params: {
resolveDurationForElement: (element: Element) => resolveDurationForElement(element),
};
}
export type { RuntimeTimelineLike } from "./types";