Files
hyperframes/packages/core/src/runtime/startResolver.ts
T
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00

199 lines
8.1 KiB
TypeScript

import type { RuntimeTimelineLike } from "./types";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { readMediaStart } from "./playbackRate";
import { parseNumeric, parseStartExpression } from "./startExpression";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
function parseDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-duration"));
}
function parseEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-end"));
}
function parseAuthoredDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_DURATION_ATTR));
}
function parseAuthoredEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_END_ATTR));
}
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 = doc.getElementById(refId);
if (byId) return byId;
return (
(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;
let resolved: number | null = null;
const durationAttr =
parseDurationAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredDurationAttr(element) : null);
if (durationAttr != null && durationAttr > 0) {
resolved = durationAttr;
}
if (resolved == null || resolved <= 0) {
const endAttr =
parseEndAttr(element) ??
(includeAuthoredTimingAttrs ? parseAuthoredEndAttr(element) : null);
if (endAttr != null) {
const start = resolveStartForElementInternal(element, 0);
const delta = endAttr - start;
if (Number.isFinite(delta) && delta > 0) {
resolved = delta;
}
}
}
if ((resolved == null || resolved <= 0) && isMediaElement(element)) {
const playbackStart = readMediaStart(element);
if (Number.isFinite(element.duration) && element.duration > playbackStart) {
resolved = (element.duration - playbackStart) / readElementPlaybackRate(element);
}
}
if (resolved == null || resolved <= 0) {
const compositionId = element.getAttribute("data-composition-id");
if (compositionId) {
const timeline = timelineRegistry[compositionId] ?? null;
if (timeline && typeof timeline.duration === "function") {
try {
const timelineDuration = Number(timeline.duration());
if (Number.isFinite(timelineDuration) && timelineDuration > 0) {
resolved = timelineDuration;
}
} catch (err) {
// ignore broken timeline impls
swallow("runtime.startResolver.site1", err);
}
}
}
}
if (resolved != null && Number.isFinite(resolved) && resolved > 0) {
durationCache.set(element, resolved);
return resolved;
}
durationCache.set(element, null);
return null;
};
const resolveHostOffsetForElement = (element: Element, fallback: number): number => {
if (element.hasAttribute("data-composition-id")) {
const parentComposition = element.parentElement?.closest("[data-composition-id]");
if (!parentComposition) return 0;
return resolveStartForElementInternal(parentComposition, fallback);
}
const compositionRoot = element.closest("[data-composition-id]");
if (!compositionRoot) return 0;
return resolveStartForElementInternal(compositionRoot, fallback);
};
const resolveStartForElementInternal = (element: Element, fallback: number): number => {
const cached = startCache.get(element);
if (cached !== undefined) {
return cached == null ? fallback : cached;
}
if (visiting.has(element)) {
return fallback;
}
visiting.add(element);
try {
const expression = parseStartExpression(element.getAttribute("data-start"));
if (!expression) {
// If this element is a loaded composition inner root (has data-composition-id
// but no data-start), walk up to the host parent which carries the actual
// timing. This happens when the host uses a different data-composition-id
// than the loaded file — e.g. host="montage" but file has "scene-10", or
// when the host itself has no data-composition-id at all (an "anonymous"
// host) and the composition's own id was restored onto the inlined wrapper.
// Check data-composition-src (runtime, not yet inlined), data-composition-id
// (bundled/compiled host with its own id), and data-composition-file (the
// marker every inlined host gets, compiled or bundled, once
// data-composition-src is stripped — covers the anonymous-host case).
if (element.hasAttribute("data-composition-id")) {
const parent = element.parentElement;
if (
parent &&
(parent.hasAttribute("data-composition-src") ||
parent.hasAttribute("data-composition-id") ||
parent.hasAttribute("data-composition-file"))
) {
const parentStart = resolveStartForElementInternal(parent, fallback);
startCache.set(element, parentStart);
return parentStart;
}
}
startCache.set(element, fallback);
return fallback;
}
if (expression.kind === "absolute") {
const absolute = Math.max(0, expression.value);
const resolved = Math.max(0, resolveHostOffsetForElement(element, fallback) + absolute);
startCache.set(element, resolved);
return resolved;
}
const target = findReferenceTarget(expression.refId);
if (!target) {
startCache.set(element, fallback);
return fallback;
}
const targetStart = resolveStartForElementInternal(target, 0);
const targetDuration = resolveDurationForElement(target);
if (targetDuration == null || targetDuration <= 0) {
const unresolved = Math.max(0, targetStart + expression.offset);
startCache.set(element, unresolved);
return unresolved;
}
const resolved = Math.max(0, targetStart + targetDuration + expression.offset);
startCache.set(element, resolved);
return resolved;
} finally {
visiting.delete(element);
}
};
return {
resolveStartForElement: (element: Element, fallback = 0) =>
resolveStartForElementInternal(element, Math.max(0, fallback)),
resolveDurationForElement: (element: Element) => resolveDurationForElement(element),
};
}
export type { RuntimeTimelineLike } from "./types";