Files
hyperframes/packages/core/src/runtime/init.ts
T

2845 lines
112 KiB
TypeScript

// fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { createCssAdapter } from "./adapters/css";
import { createGsapAdapter } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
import { createMapboxAdapter } from "./adapters/mapbox";
import { createLeafletAdapter } from "./adapters/leaflet";
import { createGoogleMapsAdapter } from "./adapters/google-maps";
import { createMaplibreAdapter } from "./adapters/maplibre";
import { createD3Adapter } from "./adapters/d3";
import { createTypegpuAdapter } from "./adapters/typegpu";
import {
patchVideoTextureCompat,
patchWebGLVideoTextureCompat,
} from "./adapters/video-texture-compat";
import { forceDispatchSeekEvent } from "./adapters/seek-dispatch";
import { createWaapiAdapter } from "./adapters/waapi";
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { createPickerModule } from "./picker";
import { createRuntimePlayer } from "./player";
import { createRuntimeState } from "./state";
import { collectRuntimeTimelinePayload } from "./timeline";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
type ExportRenderFpsResolution = {
fps: number | null;
source: "render-options" | "default" | "unknown";
rawFpsSource: unknown;
rawFps: unknown;
fallbackReason?: "missing" | "invalid";
};
function resolveExportRenderFps(): ExportRenderFpsResolution {
const config = window.__HF_EXPORT_RENDER_SEEK_CONFIG;
const rawFps = config?.fps;
const rawFpsSource = config?.fpsSource;
const fps = Number(rawFps);
if (!config || rawFps == null) {
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "missing" };
}
if (!Number.isFinite(fps) || fps <= 0) {
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "invalid" };
}
const source =
rawFpsSource === "render-options" || rawFpsSource === "default" ? rawFpsSource : "unknown";
return {
fps,
source,
rawFpsSource,
rawFps,
fallbackReason: config.fpsFallbackReason,
};
}
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
const exportRenderFps = resolveExportRenderFps();
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
console.info("[hyperframes] render runtime fps", {
canonicalFps: state.canonicalFps,
source: exportRenderFps.source,
rawFpsSource: exportRenderFps.rawFpsSource,
rawFps: exportRenderFps.rawFps,
fallbackReason: exportRenderFps.fallbackReason,
});
}
let colorGradingRuntime: RuntimeColorGradingApi | null = null;
let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null;
let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
const runtimeCleanupCallbacks: Array<() => void> = [];
const postedDiagnosticKeys = new Set<string>();
let rootStageDiagnosticRafId: number | null = null;
if (typeof window.__hfRuntimeTeardown === "function") {
try {
window.__hfRuntimeTeardown();
} catch (err) {
// keep runtime resilient across reinits
swallow("runtime.init.site1", err);
}
}
// Normalize html/body so browser defaults (8px margin, white background) never
// bleed into renders as white bars. Runs in both preview and render contexts,
// eliminating the preview/render parity gap that existed when only the React
// component's normalizePreviewViewport call applied this normalization.
if (document.documentElement) {
document.documentElement.style.margin = "0";
document.documentElement.style.padding = "0";
document.documentElement.style.overflow = "hidden";
}
if (document.body) {
document.body.style.margin = "0";
document.body.style.padding = "0";
document.body.style.overflow = "hidden";
}
window.__timelines = window.__timelines || {};
// Resolve the root composition element with the same priority the rest of
// the runtime uses (explicit `data-root` marker first, then the topmost
// non-nested composition, then first in DOM order). Defined here so the
// array-normalization + data-start defaults below pick the same root the
// closure-based `resolveRootCompositionElement` does on multi-comp pages.
const findRootCompositionEl = (): HTMLElement | null => {
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) return explicitRoot;
const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
return (
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
nodes[0] ??
null
);
};
// Agents often write `window.__timelines = [tl]` (array) instead of the
// keyed-by-composition-id object the runtime expects. Normalize at init so
// the rest of the pipeline can assume a Record<string, timeline>.
if (Array.isArray(window.__timelines)) {
const arr = window.__timelines as unknown[];
const rootId = findRootCompositionEl()?.getAttribute("data-composition-id") ?? "root";
const normalized: Record<string, unknown> = {};
if (arr.length === 1) {
normalized[rootId] = arr[0];
} else {
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
}
(window as Record<string, unknown>).__timelines = normalized;
}
// Agents sometimes omit data-start on the root composition element. The
// runtime skips timed-visibility for elements without it, making clips
// invisible and timelines non-seekable. Default to 0 for the root.
const rootComp = findRootCompositionEl();
if (rootComp && !rootComp.hasAttribute("data-start")) {
rootComp.setAttribute("data-start", "0");
}
const registerRuntimeCleanup = (callback: () => void) => {
runtimeCleanupCallbacks.push(callback);
};
const postRuntimeDiagnosticOnce = (
code: string,
details: Record<string, RuntimeJson>,
dedupeKey?: string,
) => {
const key = dedupeKey ?? `${code}:${JSON.stringify(details)}`;
if (postedDiagnosticKeys.has(key)) {
return;
}
postedDiagnosticKeys.add(key);
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code,
details,
});
};
const createPlayerApiCompat = (basePlayer: {
_timeline: RuntimeTimelineLike | null;
play: () => void;
pause: () => void;
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void;
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
renderSeek: (timeSeconds: number) => void;
}): PlayerAPI => {
const defaultStageZoom: ReturnType<PlayerAPI["getStageZoom"]> = {
scale: 1,
focusX: 960,
focusY: 540,
};
const emptyStageZoomKeyframes: ReturnType<PlayerAPI["getStageZoomKeyframes"]> = [];
const emptyVisibleElements: ReturnType<PlayerAPI["getVisibleElements"]> = [];
const defaultRenderState: ReturnType<PlayerAPI["getRenderState"]> = {
time: basePlayer.getTime(),
duration: basePlayer.getDuration(),
isPlaying: basePlayer.isPlaying(),
renderMode: false,
timelineDirty: false,
};
return {
play: basePlayer.play,
pause: basePlayer.pause,
seek: basePlayer.seek,
getTime: basePlayer.getTime,
getDuration: basePlayer.getDuration,
isPlaying: basePlayer.isPlaying,
getMainTimeline: () => null,
getElementBounds: () => {},
getElementsAtPoint: () => {},
setElementPosition: () => {},
previewElementPosition: () => {},
setElementKeyframes: () => {},
setElementScale: () => {},
setElementFontSize: () => {},
setElementTextContent: () => {},
setElementTextColor: () => {},
setElementTextShadow: () => {},
setElementTextFontWeight: () => {},
setElementTextFontFamily: () => {},
setElementTextOutline: () => {},
setElementTextHighlight: () => {},
setElementVolume: () => {},
setStageZoom: () => {},
getStageZoom: () => defaultStageZoom,
setStageZoomKeyframes: () => {},
getStageZoomKeyframes: () => emptyStageZoomKeyframes,
addElement: () => false,
removeElement: () => false,
updateElementTiming: () => false,
setElementTiming: () => {},
updateElementSrc: () => false,
updateElementLayer: () => false,
updateElementBasePosition: () => false,
markTimelineDirty: () => {},
isTimelineDirty: () => false,
rebuildTimeline: () => {},
ensureTimeline: () => {},
enableRenderMode: () => {},
disableRenderMode: () => {},
renderSeek: basePlayer.renderSeek,
getElementVisibility: () => ({ visible: false }),
getVisibleElements: () => emptyVisibleElements,
getRenderState: () => ({
...defaultRenderState,
time: basePlayer.getTime(),
duration: basePlayer.getDuration(),
isPlaying: basePlayer.isPlaying(),
}),
};
};
const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60;
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
const PLAY_REBIND_HOLD_SECONDS = 2;
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
const METADATA_REBIND_DEBOUNCE_MS = 100;
const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 240;
const normalizeDiagnosticMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message || String(value);
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value ?? "");
}
};
const classifyRuntimeScriptFailure = (
rawMessage: string,
): {
code: string;
category: string;
} => {
const message = rawMessage.toLowerCase();
if (
message.includes("cannot read properties of null") ||
message.includes("cannot set properties of null")
) {
return { code: "runtime_null_dom_access", category: "dom-null-access" };
}
if (message.includes("failed to execute 'queryselector'")) {
return { code: "runtime_invalid_selector", category: "selector-invalid" };
}
if (message.includes("is not defined")) {
return { code: "runtime_reference_missing", category: "reference-missing" };
}
return { code: "runtime_script_error", category: "script-error" };
};
const parseDimensionPx = (value: string | null): string | null => {
if (value == null || value.trim() === "") return null;
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return `${parsed}px`;
};
const resolveRootCompositionElement = (): HTMLElement | null => findRootCompositionEl();
const applyCompositionSizing = () => {
const rootEl = resolveRootCompositionElement();
if (!rootEl) return;
const forcedWidth = parseDimensionPx(rootEl.getAttribute("data-width"));
const forcedHeight = parseDimensionPx(rootEl.getAttribute("data-height"));
if (forcedWidth) rootEl.style.width = forcedWidth;
if (forcedHeight) rootEl.style.height = forcedHeight;
if (forcedWidth) rootEl.style.setProperty("--comp-width", forcedWidth);
if (forcedHeight) rootEl.style.setProperty("--comp-height", forcedHeight);
};
const sanitizeCompositionDurationAttributes = () => {
const rootEl = resolveRootCompositionElement();
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")).filter(
(n) => n.hasAttribute("data-duration") || n.hasAttribute("data-end"),
) as HTMLElement[];
for (const node of compositionNodes) {
// Preserve explicit root duration so timeline payload can distinguish
// authored finite duration from loop-inflated timeline duration.
if (rootEl && node === rootEl) continue;
// Preserve authored timing for reference-start resolution in Studio and
// timeline payload generation. The runtime still strips the public attrs
// so visibility/parity continues to derive from the live sub-timeline.
const authoredDuration = node.getAttribute("data-duration");
const authoredEnd = node.getAttribute("data-end");
if (authoredDuration != null && !node.hasAttribute(AUTHORED_DURATION_ATTR)) {
node.setAttribute(AUTHORED_DURATION_ATTR, authoredDuration);
}
if (authoredEnd != null && !node.hasAttribute(AUTHORED_END_ATTR)) {
node.setAttribute(AUTHORED_END_ATTR, authoredEnd);
}
// Strip public timing attrs on non-root compositions after preserving
// authored values privately. Runtime timing can still distinguish
// authored host windows from live child timeline durations.
node.removeAttribute("data-duration");
node.removeAttribute("data-end");
}
};
const applyClipLayout = () => {
const rootEl = resolveRootCompositionElement();
if (!rootEl) return;
if (!rootEl.style.position) {
rootEl.style.position = "relative";
}
rootEl.style.overflow = "hidden";
const rootWidth = parseDimensionPx(rootEl.getAttribute("data-width"));
const rootHeight = parseDimensionPx(rootEl.getAttribute("data-height"));
if (rootWidth) rootEl.style.width = rootWidth;
if (rootHeight) rootEl.style.height = rootHeight;
const children = Array.from(rootEl.children) as HTMLElement[];
for (const el of children) {
const tag = el.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
if (!el.hasAttribute("data-start")) continue;
// Runtime-stamped clips are NOT authored overlay clips. In Studio/preview
// the runtime stamps `data-start` onto ID'd or GSAP-targeted flow children
// (a <header>/<footer> in a flex column) so the design panel can discover
// them — see the stamping pass in bindCapturedTimeline. Forcing those out
// of document flow collapses the layout: the footer shrink-wraps and its
// `justify-content: space-between` clusters in the top-left. Leave them in
// flow so the preview matches the rendered video, which never stamps
// (production renders run as the top-level page, not in an iframe).
if (el.hasAttribute("data-hf-autostamped")) continue;
const hasLegacyAnchoredDefaults =
(el.style.top === "0px" || el.style.top === "0") &&
(el.style.left === "0px" || el.style.left === "0") &&
el.style.width === "100%" &&
el.style.height === "100%";
const hasCenteringTransform = /translate\(\s*-50%\s*,\s*-50%\s*\)/.test(el.style.transform);
if (
hasLegacyAnchoredDefaults &&
hasCenteringTransform &&
!el.hasAttribute("data-width") &&
!el.hasAttribute("data-height")
) {
const previousTop = el.style.top;
const previousLeft = el.style.left;
const previousWidth = el.style.width;
const previousHeight = el.style.height;
el.style.top = "";
el.style.left = "";
el.style.width = "";
el.style.height = "";
const clearedComputed = window.getComputedStyle(el);
const cssProvidesClipLayout =
clearedComputed.top !== "auto" ||
clearedComputed.bottom !== "auto" ||
clearedComputed.left !== "auto" ||
clearedComputed.right !== "auto" ||
clearedComputed.width !== "0px" ||
clearedComputed.height !== "0px";
if (!cssProvidesClipLayout) {
el.style.top = previousTop;
el.style.left = previousLeft;
el.style.width = previousWidth;
el.style.height = previousHeight;
}
}
const computed = window.getComputedStyle(el);
const computedPosition = computed.position;
// Root-level timed clips should stack in the same viewport layer.
// Relative positioning keeps clips in document flow and can push later
// compositions below the viewport (eg. checkerboard-style overlays).
const shouldForceAbsolute = computedPosition !== "absolute" && computedPosition !== "fixed";
if (shouldForceAbsolute) {
el.style.position = "absolute";
}
const hasExplicitVerticalAnchor =
Boolean(el.style.top) ||
Boolean(el.style.bottom) ||
computed.top !== "auto" ||
computed.bottom !== "auto";
if (!hasExplicitVerticalAnchor) {
el.style.top = "0";
}
const hasExplicitHorizontalAnchor =
Boolean(el.style.left) ||
Boolean(el.style.right) ||
computed.left !== "auto" ||
computed.right !== "auto";
if (!hasExplicitHorizontalAnchor) {
el.style.left = "0";
}
if (tag !== "audio") {
const forcedWidth = parseDimensionPx(el.getAttribute("data-width"));
const forcedHeight = parseDimensionPx(el.getAttribute("data-height"));
const hasMeaningfulComputedWidth = computed.width !== "0px" && computed.width !== "auto";
const hasMeaningfulComputedHeight = computed.height !== "0px" && computed.height !== "auto";
if (forcedWidth) {
if (!el.style.width && !hasMeaningfulComputedWidth) {
el.style.width = forcedWidth;
}
} else if (!el.style.width && computed.width === "0px") {
el.style.width = "100%";
}
if (forcedHeight) {
if (!el.style.height && !hasMeaningfulComputedHeight) {
el.style.height = forcedHeight;
}
} else if (!el.style.height && computed.height === "0px") {
el.style.height = "100%";
}
}
}
};
const resolveStartForElement = (
element: Element,
fallback = 0,
opts?: { includeAuthoredTimingAttrs?: boolean },
): number => {
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: (window.__timelines ?? {}) as Record<
string,
RuntimeTimelineLike | undefined
>,
includeAuthoredTimingAttrs: opts?.includeAuthoredTimingAttrs ?? true,
});
return resolver.resolveStartForElement(element, fallback);
};
const resolveDurationForElement = (
element: Element,
opts?: { includeAuthoredTimingAttrs?: boolean },
): number | null => {
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: (window.__timelines ?? {}) as Record<
string,
RuntimeTimelineLike | undefined
>,
includeAuthoredTimingAttrs: opts?.includeAuthoredTimingAttrs ?? true,
});
return resolver.resolveDurationForElement(element);
};
const resolveMediaStartSeconds = (element: Element, fallback = 0): number => {
if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) {
return Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
}
return resolveStartForElement(element, fallback);
};
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
return false;
}
const start =
tag === "video" || tag === "audio"
? resolveMediaStartSeconds(rawNode, 0)
: resolveStartForElement(rawNode, 0);
let duration = resolveDurationForElement(rawNode);
const compId = rawNode.getAttribute("data-composition-id");
if (compId) {
const compTimeline = (window.__timelines ?? {})[compId];
let liveDuration: number | null = null;
if (compTimeline && typeof compTimeline.duration === "function") {
const compDur = Number(compTimeline.duration());
if (Number.isFinite(compDur) && compDur > 0) {
liveDuration = compDur;
}
}
const hasAuthoredTiming =
rawNode.hasAttribute("data-duration") ||
rawNode.hasAttribute("data-end") ||
rawNode.hasAttribute(AUTHORED_DURATION_ATTR) ||
rawNode.hasAttribute(AUTHORED_END_ATTR);
if (!hasAuthoredTiming && (duration == null || duration <= 0) && liveDuration != null) {
duration = liveDuration;
}
}
const computedEnd =
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
return (
currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true)
);
};
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
let hasInlineTemplateCompositions = false;
{
const candidates = document.querySelectorAll(
"[data-composition-id]:not([data-composition-src])",
);
for (const el of candidates) {
const cid = el.getAttribute("data-composition-id");
if (
cid &&
el.children.length === 0 &&
document.querySelector(`template#${CSS.escape(cid)}-template`)
) {
hasInlineTemplateCompositions = true;
break;
}
}
}
let externalCompositionsReady = !hasExternalCompositions && !hasInlineTemplateCompositions;
const getTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null): number | null => {
if (!timeline || typeof timeline.duration !== "function") return null;
try {
const raw = Number(timeline.duration());
if (!Number.isFinite(raw)) return null;
return Math.max(0, raw);
} catch {
return null;
}
};
const isUsableTimelineDuration = (durationSeconds: number | null): durationSeconds is number =>
typeof durationSeconds === "number" &&
Number.isFinite(durationSeconds) &&
durationSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS;
type TimelineResolution = {
timeline: RuntimeTimelineLike | null;
selectedTimelineIds?: string[];
selectedDurationSeconds?: number | null;
mediaDurationFloorSeconds?: number | null;
diagnostics?: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
};
};
const resolveMediaElementDurationSeconds = (node: HTMLMediaElement): number | null => {
const declaredDuration = Number(node.getAttribute("data-duration"));
if (Number.isFinite(declaredDuration) && declaredDuration > 0) {
return declaredDuration;
}
const playbackStart = Number(
node.getAttribute("data-playback-start") ?? node.getAttribute("data-media-start") ?? "0",
);
const safePlaybackStart = Number.isFinite(playbackStart) ? Math.max(0, playbackStart) : 0;
if (Number.isFinite(node.duration) && node.duration > safePlaybackStart) {
return Math.max(0, node.duration - safePlaybackStart);
}
return null;
};
const resolveMediaWindowDurationSeconds = (): number | null => {
const mediaNodes = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as HTMLMediaElement[];
if (mediaNodes.length === 0) return null;
let maxWindowEndSeconds = 0;
for (const node of mediaNodes) {
const start = resolveMediaStartSeconds(node, 0);
if (!Number.isFinite(start)) continue;
const duration = resolveMediaElementDurationSeconds(node);
if (duration == null || duration <= MIN_VALID_TIMELINE_DURATION_SECONDS) continue;
maxWindowEndSeconds = Math.max(maxWindowEndSeconds, Math.max(0, start) + duration);
}
return maxWindowEndSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxWindowEndSeconds : null;
};
const resolveAuthoredCompositionDurationFloorSeconds = (): number | null => {
const rootEl = resolveRootCompositionElement();
if (!rootEl) return null;
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const startResolver = createRuntimeStartTimeResolver({
timelineRegistry: timelines,
includeAuthoredTimingAttrs: true,
});
let maxWindowEndSeconds = 0;
// The root's own data-duration is the authored source of truth for
// composition length. Without it in the floor, a GSAP timeline that ends
// even slightly short of the declared duration shrinks the playable
// window — and duration-gated consumers (e.g. the studio's adapter
// selection) silently reject the runtime player, losing audio playback.
const rootDeclaredSeconds = Number.parseFloat(rootEl.getAttribute("data-duration") ?? "");
if (Number.isFinite(rootDeclaredSeconds) && rootDeclaredSeconds > 0) {
maxWindowEndSeconds = rootDeclaredSeconds;
}
const compositionNodes = Array.from(
rootEl.querySelectorAll("[data-composition-id][data-start]"),
);
for (const node of compositionNodes) {
if (!(node instanceof Element)) continue;
const parentComposition = node.parentElement?.closest("[data-composition-id]");
if (parentComposition !== rootEl) continue;
const start = startResolver.resolveStartForElement(node, 0);
const duration = startResolver.resolveDurationForElement(node);
if (!Number.isFinite(start) || duration == null || duration <= 0) continue;
maxWindowEndSeconds = Math.max(maxWindowEndSeconds, Math.max(0, start) + duration);
}
return maxWindowEndSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxWindowEndSeconds : null;
};
const resolveMediaDurationFloorSeconds = (): number | null => {
const mediaWindowDuration = resolveMediaWindowDurationSeconds();
if (
typeof mediaWindowDuration !== "number" ||
!Number.isFinite(mediaWindowDuration) ||
mediaWindowDuration <= MIN_VALID_TIMELINE_DURATION_SECONDS
) {
return null;
}
return mediaWindowDuration;
};
const resolveMinCandidateDurationSeconds = (mediaDurationFloorSeconds: number | null): number => {
if (!isUsableTimelineDuration(mediaDurationFloorSeconds)) {
return MIN_VALID_TIMELINE_DURATION_SECONDS;
}
return Math.max(
MIN_VALID_TIMELINE_DURATION_SECONDS,
mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO,
);
};
const getSafeTimelineDurationSeconds = (
timeline: RuntimeTimelineLike | null,
fallback = 0,
): number => {
const timelineDuration = getTimelineDurationSeconds(timeline);
const mediaFloor = resolveMediaDurationFloorSeconds();
const authoredCompositionFloor = resolveAuthoredCompositionDurationFloorSeconds();
const durationFloor = Math.max(mediaFloor ?? 0, authoredCompositionFloor ?? 0);
const fallbackDuration =
Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
let safeDuration = 0;
// Timeline is the source of truth for authored composition duration.
if (isUsableTimelineDuration(timelineDuration)) {
safeDuration = Math.max(timelineDuration, durationFloor, fallbackDuration);
} else if (isUsableTimelineDuration(durationFloor)) {
safeDuration = Math.max(durationFloor, fallbackDuration);
} else {
safeDuration = fallbackDuration;
}
return safeDuration > 0 ? Math.max(0, safeDuration) : 0;
};
const resolveRootTimelineFromDocument = (): TimelineResolution => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
// DX fallback (#6): when the root timeline cannot be resolved by id but
// EXACTLY ONE usable timeline is registered, bind it rather than silently
// rendering the frozen t=0 DOM. Safe because with a single registered
// timeline there is no ambiguity about which one is the composition's
// root. Multiple registered → ambiguous, so we still return null and let
// the loud warning fire.
const resolveSoleTimelineFallback = (reason: string): TimelineResolution => {
const usable = Object.entries(timelines).filter(
(entry): entry is [string, RuntimeTimelineLike] =>
!!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function",
);
if (usable.length !== 1) return { timeline: null };
const [soleId, soleTimeline] = usable[0];
return {
timeline: soleTimeline,
selectedTimelineIds: [soleId],
selectedDurationSeconds: getTimelineDurationSeconds(soleTimeline),
diagnostics: {
code: "root_timeline_sole_registered_fallback",
details: { reason, soleTimelineId: soleId },
},
};
};
const startResolver = createRuntimeStartTimeResolver({
timelineRegistry: timelines,
includeAuthoredTimingAttrs: true,
});
const mediaDurationFloorSeconds = resolveMediaDurationFloorSeconds();
const authoredCompositionDurationFloorSeconds =
resolveAuthoredCompositionDurationFloorSeconds();
const durationFloorSeconds =
Math.max(mediaDurationFloorSeconds ?? 0, authoredCompositionDurationFloorSeconds ?? 0) ||
null;
const minCandidateDurationSeconds = resolveMinCandidateDurationSeconds(durationFloorSeconds);
const resolveCompositionStartSeconds = (compositionId: string): number => {
const node = document.querySelector(
`[data-composition-id="${CSS.escape(compositionId)}"]`,
) as Element | null;
if (!node) return 0;
return startResolver.resolveStartForElement(node, 0);
};
const createCompositeTimelineFromCandidates = (
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): RuntimeTimelineLike | null => {
const gsapApi = window.gsap;
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
for (const candidate of candidates) {
compositeTimeline.add(
candidate.timeline,
resolveCompositionStartSeconds(candidate.compositionId),
);
}
return compositeTimeline;
};
const createDurationFloorTimeline = (
durationSeconds: number,
existingRootTimeline: RuntimeTimelineLike | null,
): RuntimeTimelineLike | null => {
if (!isUsableTimelineDuration(durationSeconds)) return null;
const gsapApi = window.gsap;
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
const fallbackTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
if (existingRootTimeline) {
try {
fallbackTimeline.add(existingRootTimeline, 0);
} catch (err) {
// keep fallback resilient if root add fails
swallow("runtime.init.site2", err);
}
}
const withTween = fallbackTimeline as RuntimeTimelineLike & {
to?: (target: object, vars: { duration?: number }) => unknown;
};
if (typeof withTween.to === "function") {
try {
withTween.to({}, { duration: durationSeconds });
} catch (err) {
// no-op; if tween creation fails, caller will discard by unusable duration
swallow("runtime.init.site3", err);
}
}
return fallbackTimeline;
};
const addMissingChildCandidatesToRootTimeline = (
rootTimeline: RuntimeTimelineLike,
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): string[] => {
const rootWithChildren = rootTimeline as RuntimeTimelineLike & {
getChildren?: (...args: unknown[]) => unknown[];
};
if (typeof rootWithChildren.getChildren !== "function") return [];
try {
const existingChildren = rootWithChildren.getChildren(true, true, true) ?? [];
if (!Array.isArray(existingChildren)) return [];
const addedIds: string[] = [];
for (const candidate of candidates) {
const alreadyIncluded = existingChildren.some((child) => child === candidate.timeline);
if (alreadyIncluded) continue;
try {
const startSec = resolveCompositionStartSeconds(candidate.compositionId);
rootTimeline.add(candidate.timeline, startSec);
addedIds.push(candidate.compositionId);
} catch (err) {
// ignore broken child add attempts
swallow("runtime.init.site4", err);
}
}
return addedIds;
} catch {
return [];
}
};
const rootCompositionNode = resolveRootCompositionElement();
const rootCompositionId = rootCompositionNode?.getAttribute("data-composition-id") ?? null;
if (!rootCompositionId) {
return resolveSoleTimelineFallback("root_missing_composition_id");
}
const rootTimeline = timelines[rootCompositionId] ?? null;
const collectRootChildCandidates = (): Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}> => {
if (!rootCompositionNode) return [];
const seen = new Set<string>();
const childNodes = Array.from(rootCompositionNode.querySelectorAll("[data-composition-id]"));
const candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}> = [];
for (const childNode of childNodes) {
const childId = childNode.getAttribute("data-composition-id");
if (!childId || childId === rootCompositionId) continue;
if (seen.has(childId)) continue;
seen.add(childId);
const candidateTimeline = timelines[childId] ?? null;
if (!candidateTimeline) continue;
if (
typeof candidateTimeline.play !== "function" ||
typeof candidateTimeline.pause !== "function"
) {
continue;
}
const candidateDuration = getTimelineDurationSeconds(candidateTimeline);
candidates.push({
compositionId: childId,
timeline: candidateTimeline,
durationSeconds: candidateDuration ?? 0,
});
}
return candidates;
};
const rootChildCandidates = collectRootChildCandidates();
const ensureChildCandidatesActive = (
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): void => {
for (const candidate of candidates) {
const timelineWithPaused = candidate.timeline as RuntimeTimelineLike & {
paused?: (value?: boolean) => unknown;
};
if (typeof timelineWithPaused.paused !== "function") continue;
try {
timelineWithPaused.paused(false);
} catch (err) {
// keep runtime resilient against timeline API quirks
swallow("runtime.init.site5", err);
}
}
};
if (rootChildCandidates.length > 0) {
ensureChildCandidatesActive(rootChildCandidates);
}
if (rootTimeline) {
const autoNestedChildren =
rootChildCandidates.length > 0
? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates)
: [];
// Mark children as bound so the polling loop stops re-resolving
if (
rootChildCandidates.length > 0 ||
!document.querySelector(
"[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])",
)
) {
childrenBound = true;
}
// Force GSAP to render the current frame so child animations show their correct state.
// Without this, children added after the root was created may still show initial styles.
if (autoNestedChildren.length > 0) {
try {
const currentTime = rootTimeline.time();
rootTimeline.seek(currentTime, false); // false = don't suppress events
} catch {
/* ignore */
}
}
const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline);
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) {
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
if (compositeTimeline && isUsableTimelineDuration(compositeDurationSeconds)) {
return {
timeline: compositeTimeline,
selectedTimelineIds,
selectedDurationSeconds: compositeDurationSeconds,
mediaDurationFloorSeconds,
diagnostics: {
code: "root_timeline_unusable_fallback",
details: {
rootCompositionId,
rootDurationSeconds,
fallbackKind: "composite_by_root_children",
minCandidateDurationSeconds,
selectedDurationSeconds: compositeDurationSeconds,
mediaDurationFloorSeconds,
authoredCompositionDurationFloorSeconds,
selectedTimelineIds,
autoNestedChildren,
},
},
};
}
const durationFloorTimeline = createDurationFloorTimeline(
durationFloorSeconds ?? 0,
rootTimeline,
);
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
return {
timeline: durationFloorTimeline,
selectedTimelineIds: [rootCompositionId],
selectedDurationSeconds: floorTimelineDurationSeconds,
mediaDurationFloorSeconds,
diagnostics: {
code: "root_timeline_unusable_media_floor_fallback",
details: {
rootCompositionId,
rootDurationSeconds,
fallbackKind: "media_duration_floor",
mediaDurationFloorSeconds,
authoredCompositionDurationFloorSeconds,
selectedDurationSeconds: floorTimelineDurationSeconds,
selectedTimelineIds: [rootCompositionId],
autoNestedChildren,
},
},
};
}
}
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length === 0) {
const durationFloorTimeline = createDurationFloorTimeline(
durationFloorSeconds ?? 0,
rootTimeline,
);
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
return {
timeline: durationFloorTimeline,
selectedTimelineIds: [rootCompositionId],
selectedDurationSeconds: floorTimelineDurationSeconds,
mediaDurationFloorSeconds,
diagnostics: {
code: "root_timeline_unusable_media_floor_fallback",
details: {
rootCompositionId,
rootDurationSeconds,
fallbackKind: "media_duration_floor",
mediaDurationFloorSeconds,
authoredCompositionDurationFloorSeconds,
selectedDurationSeconds: floorTimelineDurationSeconds,
selectedTimelineIds: [rootCompositionId],
},
},
};
}
}
// If the authored composition schedule meaningfully exceeds the captured
// GSAP timeline, extend the timeline in-place with a zero-duration no-op
// tween. Studio previews can inline only part of the timeline registry
// while preserving the full host schedule in data-hf-authored-duration.
const rootDeclaredDurAttr = rootCompositionNode?.getAttribute("data-duration");
const rootDeclaredDur = rootDeclaredDurAttr ? parseFloat(rootDeclaredDurAttr) : null;
const rootDurationFloorSeconds = Math.max(
isUsableTimelineDuration(rootDeclaredDur) ? rootDeclaredDur : 0,
authoredCompositionDurationFloorSeconds ?? 0,
);
if (rootDurationFloorSeconds > 0) {
if (
isUsableTimelineDuration(rootDurationFloorSeconds) &&
isUsableTimelineDuration(rootDurationSeconds) &&
// Only pad when the gap is meaningful (>= 0.5s) to avoid floating-point
// false positives on compositions whose GSAP duration is already close
// to data-duration.
rootDurationFloorSeconds >= rootDurationSeconds + 0.5
) {
const tlWithTo = rootTimeline as RuntimeTimelineLike & {
to?: (target: object, vars: { duration: number }, position: number) => unknown;
};
if (typeof tlWithTo.to === "function") {
try {
// Placing a zero-duration tween at the floor extends
// timeline.duration() to exactly that point.
tlWithTo.to({}, { duration: 0 }, rootDurationFloorSeconds);
} catch (err) {
// keep runtime resilient
swallow("runtime.init.site6", err);
}
}
const newDur = getTimelineDurationSeconds(rootTimeline);
if (isUsableTimelineDuration(newDur)) {
return {
timeline: rootTimeline,
selectedTimelineIds: [rootCompositionId],
selectedDurationSeconds: newDur,
mediaDurationFloorSeconds,
diagnostics: {
code: "root_timeline_padded_to_declared_duration",
details: {
rootCompositionId,
rootDurationSeconds,
rootDeclaredDur,
authoredCompositionDurationFloorSeconds,
newDur,
},
},
};
}
}
}
return {
timeline: rootTimeline,
selectedTimelineIds: [rootCompositionId],
selectedDurationSeconds: rootDurationSeconds,
mediaDurationFloorSeconds,
diagnostics:
autoNestedChildren.length > 0
? {
code: "root_timeline_auto_nested_children",
details: {
rootCompositionId,
selectedDurationSeconds: rootDurationSeconds,
autoNestedChildren,
},
}
: undefined,
};
}
if (rootChildCandidates.length > 0) {
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);
const compositeDurationSeconds = getTimelineDurationSeconds(compositeTimeline);
if (compositeTimeline) {
return {
timeline: compositeTimeline,
selectedTimelineIds,
selectedDurationSeconds: compositeDurationSeconds,
mediaDurationFloorSeconds,
diagnostics: {
code: "root_timeline_missing_fallback",
details: {
rootCompositionId,
fallbackKind: "composite_by_root_children",
minCandidateDurationSeconds,
selectedDurationSeconds: compositeDurationSeconds,
mediaDurationFloorSeconds,
selectedTimelineIds,
},
},
};
}
}
return resolveSoleTimelineFallback("root_composition_id_unmatched_in_registry");
};
// Track whether child composition timelines have been added to the root.
// This prevents the polling loop from skipping rebind when TARGET_DURATION
// makes the root "usable" before children register. Assumption: child scripts
// must register timelines synchronously or in the immediate microtask queue
// (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
// not be discovered.
let childrenBound = false;
// fallow-ignore-next-line complexity
const bindRootTimelineIfAvailable = (): boolean => {
if (!externalCompositionsReady) return false;
const currentTimeline = state.capturedTimeline;
const currentDuration = getTimelineDurationSeconds(currentTimeline);
const currentTimelineUsable = isUsableTimelineDuration(currentDuration);
// Skip rebind ONLY if we already have a usable timeline AND children have been bound.
// Without childrenBound check, the TARGET_DURATION spacer makes the timeline "usable"
// before child composition timelines are added, causing them to never be discovered.
if (currentTimeline && currentTimelineUsable && childrenBound) return false;
const resolution = resolveRootTimelineFromDocument();
if (!resolution.timeline) return false;
if (currentTimeline && currentTimeline === resolution.timeline) {
if (typeof currentTimeline.timeScale === "function") {
currentTimeline.timeScale(state.playbackRate);
}
return false;
}
state.capturedTimeline = resolution.timeline;
if (typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration <= 0) {
// No resolvable duration (e.g. a set()-only timeline, or one whose
// duration isn't known yet). Kick GSAP off the creation position so the
// set() renders. For a finite-but-zero timeline progress(1) === progress(0);
// for an infinite-repeat timeline this lands on the first iteration's end
// frame, which is the best we can do without a known cycle length.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(1, true);
state.capturedTimeline.progress(0, false);
state.capturedTimeline.pause();
}
}
if (boundDuration > 0) {
try {
clock.setDuration(boundDuration);
} catch {
// clock not yet initialized — duration will be set during TransportClock setup
}
if (typeof state.capturedTimeline.totalTime === "function") {
// GSAP won't render tl.set() at position 0 when the paused timeline
// starts there — play/pause/seek/totalTime are all no-ops at the
// creation position. Force the set to render by cycling progress past
// 0 (when the timeline implements it), then seek to the prior playhead
// (state.currentTime) so a rebind after a user scrub or soft-reload
// restore doesn't snap back to 0.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(0.0001, true);
}
const seekTime = Math.max(0, state.currentTime || 0);
state.capturedTimeline.totalTime(seekTime, false);
state.capturedTimeline.pause();
}
// GSAP bakes the CSS `translate` into style.transform on seek.
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
// directly here as well, since the wrapper may not be installed yet
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
}
if (resolution.diagnostics) {
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: resolution.diagnostics.code,
details: resolution.diagnostics.details,
});
}
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: "timeline_bound",
details: {
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
selectedDurationSeconds: resolution.selectedDurationSeconds ?? null,
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
},
});
// Stamp data-start / data-duration on GSAP-targeted elements that lack
// them so the Studio timeline can discover individual animated elements.
// Only when embedded in an iframe (Studio preview) — production renders
// run as the top-level page and must not mutate element timing.
if (window.parent !== window) {
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set<Element>();
// Only an AUTHORED clip (data-start already in the source, captured before
// we stamp anything) should suppress stamping its descendants. An animated
// scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
// must NOT suppress its own animated children — otherwise those children
// never become timeline clips and that scene can't inline-expand.
const authoredTimed = new Set<Element>(document.querySelectorAll("[data-start]"));
const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
let node = element.parentElement;
while (node && node !== rootComp) {
if (authoredTimed.has(node)) return true;
node = node.parentElement;
}
return false;
};
// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
for (const child of state.capturedTimeline.getChildren(true)) {
if (typeof child.targets !== "function") continue;
for (const target of child.targets()) {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
if (hasAuthoredTimedAncestor(target)) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
target.setAttribute("data-duration", dur);
// Mark as runtime-stamped so applyClipLayout leaves it in document
// flow instead of treating it as an authored overlay clip.
target.setAttribute("data-hf-autostamped", "1");
}
}
} catch {
/* timeline access guard */
}
}
// Stamp all ID'd children of the composition root so they appear
// in the timeline even without animations. Enables selecting and
// adding animations from the design panel on a blank canvas.
if (rootComp instanceof HTMLElement) {
for (const el of rootComp.querySelectorAll("[id]")) {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
if (hasAuthoredTimedAncestor(el)) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", dur);
// Mark as runtime-stamped so applyClipLayout leaves it in document
// flow instead of treating it as an authored overlay clip.
el.setAttribute("data-hf-autostamped", "1");
}
}
}
// (Re-)probe all already-bound media elements against the new timeline.
// Clear the cache first so elements probed against a prior timeline get fresh keyframes.
for (const el of metadataBoundMedia) {
volumeKeyframeCache.delete(el);
probeAndCacheVolumeKeyframes(el);
}
return true;
};
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
childrenBound = false;
bindRootTimelineIfAvailable();
};
const emitRootStageLayoutDiagnostics = () => {
const rootNode = resolveRootCompositionElement();
if (!(rootNode instanceof HTMLElement)) {
return;
}
const rect = rootNode.getBoundingClientRect();
const declaredWidth = Number(rootNode.getAttribute("data-width"));
const declaredHeight = Number(rootNode.getAttribute("data-height"));
const computedStyle = window.getComputedStyle(rootNode);
const hasDeclaredDimensions =
Number.isFinite(declaredWidth) &&
declaredWidth > 0 &&
Number.isFinite(declaredHeight) &&
declaredHeight > 0;
const looksCollapsed =
rect.width <= 0 ||
rect.height <= 0 ||
rootNode.clientWidth <= 0 ||
rootNode.clientHeight <= 0;
if (!hasDeclaredDimensions || !looksCollapsed) {
return;
}
postRuntimeDiagnosticOnce(
"root_stage_layout_zero",
{
compositionId: rootNode.getAttribute("data-composition-id") ?? null,
declaredWidth,
declaredHeight,
rectWidth: Math.round(rect.width),
rectHeight: Math.round(rect.height),
clientWidth: rootNode.clientWidth,
clientHeight: rootNode.clientHeight,
display: computedStyle.display,
visibility: computedStyle.visibility,
overflow: computedStyle.overflow,
},
`root-stage-layout-zero:${rootNode.getAttribute("data-composition-id") ?? "unknown"}`,
);
};
const scheduleRootStageLayoutDiagnostics = () => {
if (state.tornDown) {
return;
}
if (rootStageDiagnosticRafId != null) {
window.cancelAnimationFrame(rootStageDiagnosticRafId);
}
rootStageDiagnosticRafId = window.requestAnimationFrame(() => {
rootStageDiagnosticRafId = null;
emitRootStageLayoutDiagnostics();
});
};
const installRuntimeErrorDiagnostics = () => {
runtimeErrorListener = (event: ErrorEvent) => {
const normalized = normalizeDiagnosticMessage(event.error ?? event.message).slice(
0,
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
);
if (!normalized) {
return;
}
const classified = classifyRuntimeScriptFailure(normalized);
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: classified.code,
details: {
category: classified.category,
message: normalized,
filename: event.filename || null,
line: Number.isFinite(event.lineno) ? event.lineno : null,
column: Number.isFinite(event.colno) ? event.colno : null,
},
});
};
runtimeUnhandledRejectionListener = (event: PromiseRejectionEvent) => {
const normalized = normalizeDiagnosticMessage(event.reason).slice(
0,
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
);
if (!normalized) {
return;
}
const classified = classifyRuntimeScriptFailure(normalized);
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: `${classified.code}_unhandled_rejection`,
details: {
category: `${classified.category}-unhandled-rejection`,
message: normalized,
},
});
};
window.addEventListener("error", runtimeErrorListener);
window.addEventListener("unhandledrejection", runtimeUnhandledRejectionListener);
};
const installAssetFailureDiagnostics = () => {
const assetNodes = Array.from(
document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"),
);
for (const node of assetNodes) {
const onError = () => {
if (!(node instanceof Element)) {
return;
}
const tagName = node.tagName.toLowerCase();
const assetUrl =
node.getAttribute("src") ??
node.getAttribute("href") ??
node.getAttribute("poster") ??
null;
const diagnosticCode =
tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
postRuntimeDiagnosticOnce(
diagnosticCode,
{
tagName,
assetUrl,
currentSrc:
node instanceof HTMLImageElement || node instanceof HTMLMediaElement
? node.currentSrc || null
: null,
readyState: node instanceof HTMLMediaElement ? node.readyState : null,
networkState: node instanceof HTMLMediaElement ? node.networkState : null,
},
`${diagnosticCode}:${tagName}:${assetUrl ?? "unknown"}`,
);
};
node.addEventListener("error", onError);
registerRuntimeCleanup(() => {
node.removeEventListener("error", onError);
});
}
const fontSet = document.fonts;
if (!fontSet) {
return;
}
void fontSet.ready
.then(() => {
if (state.tornDown) {
return;
}
const failedFamilies = Array.from(fontSet)
.filter((face) => face.status === "error")
.map((face) => face.family)
.filter((family) => Boolean(family))
.slice(0, 10);
if (failedFamilies.length === 0) {
return;
}
postRuntimeDiagnosticOnce(
"runtime_font_load_issue",
{
failedFamilies,
totalFaces: Array.from(fontSet).length,
},
`runtime-font-load-issue:${failedFamilies.join("|")}`,
);
})
.catch(() => {
// ignore font readiness failures
});
};
const rebindTimelineFromResolution = (
resolution: TimelineResolution,
reason: "loop_guard" | "manual",
): boolean => {
if (!resolution.timeline) return false;
const previousTimeline = state.capturedTimeline;
if (previousTimeline && previousTimeline === resolution.timeline) {
return false;
}
const previousTime = Math.max(0, state.currentTime || 0);
const wasPlaying = state.isPlaying;
state.capturedTimeline = resolution.timeline;
if (typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
try {
state.capturedTimeline.pause();
state.capturedTimeline.seek(previousTime, false);
if (wasPlaying) {
state.capturedTimeline.play();
}
} catch (err) {
// keep runtime resilient even if a timeline implementation throws
swallow("runtime.init.site7", err);
}
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: "timeline_loop_guard_rebind",
details: {
reason,
previousTime,
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
selectedDurationSeconds: resolution.selectedDurationSeconds ?? null,
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
},
});
return true;
};
let metadataRebindDebounceTimerId: number | null = null;
let metadataRebindApplied = false;
const metadataBoundMedia = new Set<HTMLMediaElement>();
const volumeKeyframeCache = new WeakMap<HTMLMediaElement, VolumeKeyframe[]>();
const scheduleMetadataDurationHydration = () => {
if (state.tornDown) return;
if (metadataRebindDebounceTimerId != null) {
window.clearTimeout(metadataRebindDebounceTimerId);
}
metadataRebindDebounceTimerId = window.setTimeout(() => {
if (state.tornDown) return;
metadataRebindDebounceTimerId = null;
const resolution = resolveRootTimelineFromDocument();
if (!resolution.timeline) return;
const hasResolvedMediaFloor = isUsableTimelineDuration(
resolution.mediaDurationFloorSeconds ?? null,
);
if (!hasResolvedMediaFloor) return;
if (!state.capturedTimeline) {
if (bindRootTimelineIfAvailable()) {
postTimeline();
postState(true);
}
return;
}
if (metadataRebindApplied) return;
const currentDuration = getTimelineDurationSeconds(state.capturedTimeline);
const nextDuration =
resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
const isBetterCandidate =
isUsableTimelineDuration(nextDuration) &&
(!isUsableTimelineDuration(currentDuration) ||
nextDuration >= currentDuration + METADATA_REBIND_MIN_DURATION_GAIN_SECONDS);
if (!isBetterCandidate) return;
if (rebindTimelineFromResolution(resolution, "manual")) {
metadataRebindApplied = true;
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code: "timeline_rebind_after_media_metadata",
details: {
previousDurationSeconds: currentDuration ?? null,
selectedDurationSeconds: nextDuration ?? null,
selectedTimelineIds: resolution.selectedTimelineIds ?? [],
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
},
});
postTimeline();
postState(true);
}
}, METADATA_REBIND_DEBOUNCE_MS);
};
const unbindMediaMetadataListeners = () => {
for (const mediaEl of metadataBoundMedia) {
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
}
metadataBoundMedia.clear();
};
const bindMediaMetadataListeners = () => {
if (state.tornDown) return;
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as HTMLMediaElement[];
for (const mediaEl of mediaEls) {
if (metadataBoundMedia.has(mediaEl)) continue;
metadataBoundMedia.add(mediaEl);
const parsedVolume = Number.parseFloat(mediaEl.dataset.volume ?? "");
if (Number.isFinite(parsedVolume)) {
mediaEl.volume = Math.max(0, Math.min(1, parsedVolume));
}
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
// Eagerly preload media data so audio/video is buffered before the user
// clicks play. Without this, the first play() call fires on un-fetched
// media, producing silence or choppy audio until the browser caches it.
if (mediaEl.preload !== "auto") {
mediaEl.preload = "auto";
}
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
mediaEl.load();
}
// Probe volume automation from the GSAP timeline — same approach as the
// renderer (see discoverAudioVolumeAutomationFromTimeline / audioMixer).
// Runs only when the timeline is already captured; elements bound before
// the timeline is ready are re-probed the first time bindMediaMetadataListeners
// fires after the timeline has been captured (every 30 transport ticks).
probeAndCacheVolumeKeyframes(mediaEl);
}
};
const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => {
if (volumeKeyframeCache.has(mediaEl)) return;
probeAndCacheElementVolume(
mediaEl,
state.capturedTimeline,
getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
volumeKeyframeCache,
);
};
// fallow-ignore-next-line complexity
// Whether a timed clip participates in normal flow (static/relative/sticky).
// In-flow clips must leave the flow when hidden — `visibility:hidden` reserves
// their layout box, so a split sibling would stack below the active half
// instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper,
// and avoids disturbing absolute media playback). Computed once per element.
let timedClipInFlow = new WeakMap<Element, boolean>();
const isTimedClipInFlow = (el: HTMLElement): boolean => {
const cached = timedClipInFlow.get(el);
if (cached !== undefined) return cached;
const pos = window.getComputedStyle(el).position;
const inFlow = pos === "static" || pos === "relative" || pos === "sticky";
timedClipInFlow.set(el, inFlow);
return inFlow;
};
// `display:none` is only safe on a LEAF timed clip (no nested timed clips). On a
// container it removes the whole subtree, hiding descendants that are still inside
// their OWN visibility window — e.g. an in-flow composition root whose window
// clamps to the timeline end would black out a child video that should still
// show. `visibility:hidden` doesn't have this problem (a child can override it
// with `visibility:visible`), so containers keep that and only leaves leave-flow.
let timedClipIsLeaf = new WeakMap<Element, boolean>();
const isTimedClipLeaf = (el: HTMLElement): boolean => {
const cached = timedClipIsLeaf.get(el);
if (cached !== undefined) return cached;
const leaf = el.querySelector("[data-start]") === null;
timedClipIsLeaf.set(el, leaf);
return leaf;
};
// Both caches key on live DOM facts that change when the timed-element set
// changes: leaf status flips when a clip gains/loses a nested `[data-start]`
// descendant (sub-composition load/unload, studio insert/delete), and a swapped
// element can reuse an identity whose in-flow status differs. WeakMap has no
// `clear()`, so drop both maps wholesale — re-derived lazily on next access.
const invalidateTimedClipCaches = () => {
timedClipInFlow = new WeakMap<Element, boolean>();
timedClipIsLeaf = new WeakMap<Element, boolean>();
};
const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null;
// Media sync follows the authored host window, matching visibility for
// authored composition hosts. Live child timeline duration only fills in
// when no authored timing exists, so seeks clamp against host clip timing.
const inheritedDuration = compositionRoot
? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true })
: null;
return { compositionRoot, inheritedStart, inheritedDuration };
};
const cache = refreshRuntimeMediaCache({
shouldIncludeElement: (element) =>
element.hasAttribute("data-start") ||
Boolean(resolveMediaCompositionContext(element).compositionRoot),
resolveStartSeconds: (element) => {
const context = resolveMediaCompositionContext(
element as HTMLVideoElement | HTMLAudioElement,
);
return resolveMediaStartSeconds(element, context.inheritedStart ?? 0);
},
resolveDurationSeconds: (element) => {
const context = resolveMediaCompositionContext(element);
const start = resolveMediaStartSeconds(element, context.inheritedStart ?? 0);
const mediaStart =
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
0;
const hostRemaining =
context.inheritedStart != null &&
context.inheritedDuration != null &&
context.inheritedDuration > 0
? Math.max(0, context.inheritedStart + context.inheritedDuration - start)
: null;
const sourceDuration =
Number.isFinite(element.duration) && element.duration > mediaStart
? Math.max(0, element.duration - mediaStart)
: null;
// The element's own data-duration is an explicit clip-length trim
// (the studio writes it when you drag the clip edge). It must bound
// playback so a trimmed track stops at its edge instead of running on
// to the source-file or host-composition end. Absent → no cap (an
// untrimmed clip plays its natural source length).
const ownDuration = Number.parseFloat(element.dataset.duration ?? "");
const explicitDuration =
Number.isFinite(ownDuration) && ownDuration > 0 ? ownDuration : null;
const candidates = [sourceDuration, hostRemaining, explicitDuration].filter(
(value): value is number => value != null,
);
return candidates.length > 0 ? Math.min(...candidates) : null;
},
});
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
// same envelope the renderer uses instead of tracking GSAP-change diffs.
for (const clip of cache.mediaClips) {
const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement);
if (kf) clip.volumeKeyframes = kf;
}
const forceSync = state.mediaForceSyncNextTick;
if (forceSync) state.mediaForceSyncNextTick = false;
if (!state.nativeMediaSyncDisabled) {
syncRuntimeMedia({
clips: cache.mediaClips,
timeSeconds: state.currentTime,
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted:
state.mediaOutputMuted ||
(!state.webAudioMediaDisabled && !state.nativeMediaSyncDisabled && webAudio.isActive()),
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
},
});
}
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
const rootComp = resolveRootCompositionElement();
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
// Descendants must not override a hidden ancestor clip. CSS visibility can
// otherwise leak child pixels through inactive scenes because a descendant
// with visibility:visible escapes an ancestor's visibility:hidden.
if (isVisibleNow) {
let ancestor = rawNode.parentElement;
while (ancestor) {
if (ancestor === rootComp) break;
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
isVisibleNow = false;
break;
}
}
ancestor = ancestor.parentElement;
}
}
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
if (isVisibleNow) {
if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
} else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
rawNode.style.display = "none";
}
}
};
const postState = (force: boolean) => {
const frame = Math.max(0, Math.round((state.currentTime || 0) * state.canonicalFps));
const now = Date.now();
const shouldPost =
force ||
frame !== state.bridgeLastPostedFrame ||
state.isPlaying !== state.bridgeLastPostedPlaying ||
state.bridgeMuted !== state.bridgeLastPostedMuted ||
now - state.bridgeLastPostedAt >= state.bridgeMaxPostIntervalMs;
if (!shouldPost) return;
state.bridgeLastPostedFrame = frame;
state.bridgeLastPostedPlaying = state.isPlaying;
state.bridgeLastPostedMuted = state.bridgeMuted;
state.bridgeLastPostedAt = now;
postRuntimeMessage({
source: "hf-preview",
type: "state",
frame,
isPlaying: state.isPlaying,
muted: state.bridgeMuted,
playbackRate: state.playbackRate,
});
};
// Signature the live __clipTree was built from; rebuild only when the set of
// timed elements changes (e.g. a sub-composition finishes loading), not every
// transport tick. A plain count misses same-count swaps (one sub-comp unloads
// as another loads), so the signature keys on id+tag in document order.
let clipTreeSignature = "";
const computeClipTreeSignature = (): string => {
let sig = "";
for (const el of document.querySelectorAll("[data-start]")) {
sig += `${el.id}:${el.tagName}|`;
}
return sig;
};
const postTimeline = () => {
sanitizeCompositionDurationAttributes();
applyCompositionSizing();
applyClipLayout();
// Post resolved stage size so the parent can scale the iframe container
const stageSizeRootEl = resolveRootCompositionElement();
if (stageSizeRootEl) {
const w = parseDimensionPx(stageSizeRootEl.getAttribute("data-width"));
const h = parseDimensionPx(stageSizeRootEl.getAttribute("data-height"));
const width = w ? parseInt(w, 10) : 0;
const height = h ? parseInt(h, 10) : 0;
if (width > 0 && height > 0) {
postRuntimeMessage({ source: "hf-preview", type: "stage-size", width, height });
}
}
bindRootTimelineIfAvailable();
const payload = collectRuntimeTimelinePayload({
canonicalFps: state.canonicalFps,
});
window.__clipManifest = payload;
const currentSignature = computeClipTreeSignature();
if (clipTreeSignature !== currentSignature) {
// The timed-element set changed — leaf/in-flow caches may be stale.
invalidateTimedClipCaches();
}
if (!window.__clipTree || clipTreeSignature !== currentSignature) {
const runtimeWindow = window as Window & {
__timelines?: Record<string, RuntimeTimelineLike | undefined>;
};
window.__clipTree = createClipTree({
startResolver: createRuntimeStartTimeResolver({
timelineRegistry: runtimeWindow.__timelines ?? {},
includeAuthoredTimingAttrs: true,
}),
timelineRegistry: runtimeWindow.__timelines ?? {},
rootDuration: payload.durationInFrames / state.canonicalFps,
});
clipTreeSignature = currentSignature;
}
postRuntimeMessage(payload);
scheduleRootStageLayoutDiagnostics();
};
const runAdapters = (method: "discover" | "pause" | "play", timeSeconds = 0) => {
for (const adapter of state.deterministicAdapters) {
try {
if (method === "discover") adapter.discover();
if (method === "pause") adapter.pause();
if (method === "play" && adapter.play) adapter.play();
} catch (err) {
// keep runtime resilient against adapter-specific failures
swallow("runtime.init.site8", err);
}
if (method === "discover") {
try {
adapter.seek({ time: timeSeconds });
} catch (err) {
// ignore seek bootstrap failures
swallow("runtime.init.site9", err);
}
}
}
};
let maybePublishRenderReady = () => {
window.__renderReady = false;
};
// Internal adapter-readiness tracking. Adapters with outstanding async work
// (Three.js `DefaultLoadingManager`, future fetch/font/image detectors) expose
// a `getReadyPromise()` method; the runtime waits for whatever they return
// before publishing render-ready. This is purely internal — there is no
// authored-code-facing flag (LLMs should not need to know about render
// readiness, the framework handles async asset gating automatically).
let trackedAdapterReadyPromise: PromiseLike<unknown> | null = null;
let trackedAdapterReadySettled = true;
const collectAdapterReadyPromises = (): PromiseLike<unknown>[] => {
const promises: PromiseLike<unknown>[] = [];
for (const adapter of state.deterministicAdapters) {
const getter = adapter.getReadyPromise;
if (typeof getter !== "function") continue;
try {
const p = getter();
if (p) promises.push(p);
} catch (err) {
// A throwing readiness gate must not permanently block render; swallow
// and continue, matching the rest of the runtime's adapter-resilience
// pattern.
swallow("runtime.init.adapterReady", err);
}
}
return promises;
};
const isAdapterReadinessSettled = (): boolean => {
const promises = collectAdapterReadyPromises();
if (promises.length === 0) {
trackedAdapterReadyPromise = null;
trackedAdapterReadySettled = true;
return true;
}
// Combine multiple adapter promises so we only attach a single resume
// handler. Identity is stable as long as the inputs are stable (each
// adapter is expected to return the same promise on repeat calls while
// its work is in flight).
const combined: PromiseLike<unknown> =
promises.length === 1 ? promises[0] : Promise.all(promises);
if (combined !== trackedAdapterReadyPromise) {
trackedAdapterReadyPromise = combined;
trackedAdapterReadySettled = false;
void Promise.resolve(combined).then(
() => {
if (trackedAdapterReadyPromise !== combined) return;
trackedAdapterReadySettled = true;
maybePublishRenderReady();
},
(err) => {
if (trackedAdapterReadyPromise !== combined) return;
trackedAdapterReadySettled = true;
swallow("runtime.init.adapterReady", err);
maybePublishRenderReady();
},
);
}
return trackedAdapterReadySettled;
};
if (!externalCompositionsReady) {
const compositionLoaderParams = {
injectedStyles: state.injectedCompStyles,
injectedScripts: state.injectedCompScripts,
parseDimensionPx,
onDiagnostic: ({
code,
details,
}: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
}) => {
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
code,
details,
});
},
};
void loadExternalCompositions(compositionLoaderParams)
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
maybePublishRenderReady();
});
} else {
// No external/inline compositions to load — apply caption overrides immediately
applyCaptionOverrides();
}
const picker = createPickerModule({
postMessage: (payload) => postRuntimeMessage(payload),
});
picker.installPickerApi();
const colorGrading = createColorGradingRuntime();
colorGradingRuntime = colorGrading;
registerRuntimeCleanup(() => {
colorGrading.destroy();
colorGradingRuntime = null;
});
const applyPlaybackRate = (nextRate: number) => {
const parsed = Number(nextRate);
if (!Number.isFinite(parsed) || parsed <= 0) {
state.playbackRate = 1;
} else {
state.playbackRate = Math.max(0.1, Math.min(5, parsed));
}
state.mediaForceSyncNextTick = true;
if (state.capturedTimeline && typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
try {
el.playbackRate = state.playbackRate;
} catch (err) {
// ignore unsupported values
swallow("runtime.init.site10", err);
}
}
};
const player = createRuntimePlayer({
getTimeline: () => state.capturedTimeline,
setTimeline: (timeline) => {
state.capturedTimeline = timeline;
},
getTimelineRegistry: () =>
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
getIsPlaying: () => state.isPlaying,
setIsPlaying: (playing) => {
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
state.isPlaying = playing;
},
getPlaybackRate: () => state.playbackRate,
setPlaybackRate: applyPlaybackRate,
getCanonicalFps: () => state.canonicalFps,
onSyncMedia: (timeSeconds, playing) => {
state.currentTime = Math.max(0, Number(timeSeconds) || 0);
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
state.isPlaying = playing;
syncMediaForCurrentState();
},
onStatePost: postState,
onDeterministicSeek: (timeSeconds) => {
for (const adapter of state.deterministicAdapters) {
try {
adapter.seek({ time: Number(timeSeconds) || 0 });
} catch (err) {
// ignore adapter failure
swallow("runtime.init.site11", err);
}
}
},
onDeterministicPause: () => runAdapters("pause"),
onDeterministicPlay: () => runAdapters("play"),
onRenderFrameSeek: () => {
colorGrading.redraw();
},
onShowNativeVideos: () => {},
getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
});
window.__player = createPlayerApiCompat(player);
window.__playerReady = true;
// Wire analytics event emission through the bridge
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
emitAnalyticsEvent("composition_loaded", {
duration: player.getDuration(),
compositionId:
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
});
state.controlBridgeHandler = installRuntimeControlBridge({
onPlay: () => {
player.play();
emitAnalyticsEvent("composition_played", { time: player.getTime() });
},
onPause: () => {
player.pause();
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
},
onStopMedia: () => {
webAudio.stopAll();
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (el instanceof HTMLMediaElement && !el.paused) el.pause();
}
},
onSeek: (frame, _seekMode) => {
const time = Math.max(0, frame) / state.canonicalFps;
player.seek(time);
emitAnalyticsEvent("composition_seeked", { time });
},
onSetMuted: (muted) => {
state.bridgeMuted = muted;
const effective = muted || state.mediaOutputMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetVolume: (volume) => {
state.bridgeVolume = volume;
webAudio.setVolume(volume);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
const parsed = parseFloat(el.dataset.volume ?? "");
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
el.volume = clipVolume * volume;
}
},
onSetMediaOutputMuted: (muted) => {
state.mediaOutputMuted = muted;
const effective = muted || state.bridgeMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective || el.defaultMuted;
}
},
onSetNativeMediaSyncDisabled: (disabled) => {
if (state.nativeMediaSyncDisabled === disabled) return;
state.nativeMediaSyncDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
} else {
syncMediaForCurrentState();
}
},
onSetWebAudioMediaDisabled: (disabled) => {
if (state.webAudioMediaDisabled === disabled) return;
state.webAudioMediaDisabled = disabled;
state.mediaForceSyncNextTick = true;
if (disabled) {
webAudio.stopAll();
clock.detachAudioSource();
syncMediaForCurrentState();
} else {
syncMediaForCurrentState();
}
},
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
applyWebAudioRate();
},
onSetColorGrading: (target, grading) => {
colorGrading.setGrading(target, grading);
},
onSetColorGradingCompare: (target, compare) => {
colorGrading.setCompare(target, compare);
},
onTick: () => {
if (state.tornDown || !clock.isPlaying()) return;
const t = clock.now();
state.currentTime = t;
seekTimelineAndAdapters(t);
if (clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
}
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
});
state.deterministicAdapters = [
createWaapiAdapter(),
createCssAdapter({
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
}),
createAnimeJsAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createMapboxAdapter(),
createLeafletAdapter(),
createGoogleMapsAdapter(),
createMaplibreAdapter(),
createD3Adapter(),
createTypegpuAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
patchWebGLVideoTextureCompat();
// Lets the engine re-render GPU compositions after it injects decoded video
// frames, so video-textured WebGL/WebGPU scenes sample the correct frame.
window.__hfReseekGpu = (time: number) => {
const t = Math.max(0, Number(time) || 0);
window.__hfThreeTime = t;
window.__hfTypegpuTime = t;
forceDispatchSeekEvent(t);
};
installRuntimeErrorDiagnostics();
bindMediaMetadataListeners();
runAdapters("discover");
// ── Single-clock transport ──
//
// TransportClock is the sole time authority. GSAP is always paused —
// seeked to clock.now() on each rAF tick. This eliminates the
// two-clock drift problem from issue #668: one clock, zero drift.
const clock = new TransportClock();
state.transportClock = clock;
const webAudio = new WebAudioTransport();
let webAudioReady = false;
void webAudio.init().then((ok) => {
webAudioReady = ok;
});
const publishRenderReadyAfterTimelineBinding = () => {
const prevTimeline = state.capturedTimeline;
const rebound = bindRootTimelineIfAvailable();
if (
state.capturedTimeline &&
(rebound || state.capturedTimeline !== prevTimeline || !player._timeline)
) {
player._timeline = state.capturedTimeline;
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration > 0) {
clock.setDuration(boundDuration);
}
runAdapters("discover", state.currentTime);
// Loud, specific diagnostic for the #1 "looks fine, ships broken" trap:
// a root timeline never bound even though timelines ARE registered. Without
// this the render silently proceeds on the static build-time DOM (frozen at
// t=0). Only warn when GSAP timelines exist (CSS/WAAPI/Lottie-only
// compositions legitimately bind no GSAP timeline and use adapters).
if (!state.capturedTimeline) {
const registry = (window.__timelines ?? {}) as Record<string, unknown>;
const registeredKeys = Object.keys(registry).filter((k) => registry[k]);
if (registeredKeys.length > 0) {
const rootEl = resolveRootCompositionElement();
const rootCompositionId = rootEl?.getAttribute("data-composition-id") ?? null;
postRuntimeDiagnosticOnce(
"root_timeline_unbound_registry_present",
{
reason: rootCompositionId
? "root data-composition-id has no matching key in window.__timelines"
: "root composition element has no data-composition-id attribute",
rootCompositionId,
registeredTimelineKeys: registeredKeys,
},
"root_timeline_unbound_registry_present",
);
// eslint-disable-next-line no-console -- loud author-facing warning; this render would otherwise freeze at t=0
console.warn(
`[hyperframes] Root timeline not bound — render will freeze at t=0. ` +
(rootCompositionId
? `Root data-composition-id is "${rootCompositionId}" but window.__timelines has no such key. `
: `Root composition element has no data-composition-id. `) +
`Registered timeline keys: [${registeredKeys.join(", ")}]. ` +
`Register the root timeline under its data-composition-id (window.__timelines["${rootCompositionId ?? "<root-id>"}"] = tl).`,
);
}
}
// __renderReady = timeline binding attempted, safe for deterministic seeking.
// Set after any GSAP batching has completed. renderSeek works with or
// without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only).
window.__renderReady = true;
postTimeline();
postState(true);
};
maybePublishRenderReady = () => {
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
window.__renderReady = false;
return;
}
// Re-run discover so adapters can refresh their state from the current
// DOM — e.g. the Three.js adapter only hooks `DefaultLoadingManager` once
// it sees `window.THREE`, which may have loaded AFTER the initial
// bootstrap discover. Discover is idempotent in every adapter, so a
// second call here is cheap.
runAdapters("discover", state.currentTime);
if (!isAdapterReadinessSettled()) {
window.__renderReady = false;
return;
}
publishRenderReadyAfterTimelineBinding();
};
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
// active, composition scripts queue tl.to() calls instead of executing them
// synchronously. Wait for the "hf-timelines-built" event before the first
// binding attempt so the transport clock receives the finished timeline
// duration instead of permanently publishing duration=0.
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
const onTimelinesBuilt = () => {
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
}
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
// loadExternalCompositions() is skipped. But inline scripts registering child
// timelines in __timelines haven't executed yet (they run in the browser's next
// microtask). Defer a rebinding attempt to catch them.
if (externalCompositionsReady) {
setTimeout(() => {
maybePublishRenderReady();
}, 0);
}
let transportTickCount = 0;
let inTransportTick = false;
const seekRuntimeTimeline = (
timeline: RuntimeTimelineLike,
timeSeconds: number,
swallowLabel: string,
) => {
try {
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, false);
} else {
timeline.seek(timeSeconds, false);
}
} catch (err) {
swallow(swallowLabel, err);
}
};
const seekStandaloneRegisteredTimelines = (timeSeconds: number) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const rootCompositionId =
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
for (const [compositionId, timeline] of Object.entries(timelines)) {
if (!timeline || compositionId === rootCompositionId) continue;
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`);
if (!node) continue;
const start = resolveStartForElement(node, 0);
if (!Number.isFinite(start)) continue;
const authoredDuration = resolveDurationForElement(node, {
includeAuthoredTimingAttrs: true,
});
const timelineDuration = getTimelineDurationSeconds(timeline);
const duration =
authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration;
const localTime = Math.max(
0,
duration != null && duration > 0
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline");
}
};
// Unpause all non-root timelines registered in window.__timelines (siblings
// in the registry, not GSAP child tweens). Matches the naming convention in
// player.ts:32 (forEachSiblingTimeline) and player.ts:89 (activateSiblingTimelines).
//
// Unlike the player's seek path which re-pauses siblings after seeking,
// render-seek is one-frame-at-a-time with no transport tick between frames,
// so the residual unpaused state is harmless — the next call re-activates
// idempotently.
const activateSiblingTimelines = (masterTimeline: RuntimeTimelineLike) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
for (const tl of Object.values(timelines)) {
if (!tl || tl === masterTimeline) continue;
try {
tl.play();
} catch (err) {
swallow("runtime.init.activateSiblings", err);
}
}
};
const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => {
const tl = state.capturedTimeline;
if (tl) {
// When rendering frame-by-frame (activateChildren=true), ensure all
// sibling timelines are unpaused before seeking the root. GSAP
// does not propagate totalTime() to children that are internally
// paused, which leaves sub-compositions at their initial CSS state
// (typically opacity:0). This mirrors the activateSiblingTimelines
// call in player.ts renderSeek and is critical for sub-compositions
// whose data-start is at or near 0 — they are added to the root
// while it is paused and may never receive an explicit play().
if (opts?.activateChildren) {
activateSiblingTimelines(tl);
}
// #10: when data-duration exceeds the timeline's intrinsic length the
// engine requests frames past the last tween. Seeking a paused GSAP
// timeline past its end can revert from()-tweens to their empty initial
// state, blanking the final poster. Clamp the MASTER seek to the
// timeline's full extent so it holds the final computed frame instead.
// Adapters still receive the raw `t` (their media may run longer).
// totalDuration() includes repeats; Infinity (infinite repeat) → no clamp.
const tlWithTotal = tl as RuntimeTimelineLike & { totalDuration?: () => number };
let tlSeekTime = t;
if (typeof tlWithTotal.totalDuration === "function") {
try {
const total = Number(tlWithTotal.totalDuration());
if (Number.isFinite(total) && total > 0 && t > total) {
tlSeekTime = total;
}
} catch (err) {
swallow("runtime.init.transport.clampDuration", err);
}
}
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(tlSeekTime, false);
} else {
tl.seek(tlSeekTime, false);
}
} catch (err) {
swallow("runtime.init.transport.seek", err);
}
// Sibling timelines (registered in __timelines but not nested under
// the root) are paused alongside the master. We do NOT seek them to
// absolute position `t` here — child timelines nested under the root
// are already propagated via tl.totalTime(), and seeking them again
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t);
}
for (const adapter of state.deterministicAdapters) {
try {
adapter.seek({ time: t });
} catch (err) {
swallow("runtime.init.transport.adapter", err);
}
}
};
// True while the Studio is mid-drag on an element (the gesture marker is
// stamped on the gestured element for the duration of the drag). During a
// paused gesture the draft writer owns the element's transform, so the
// per-frame transport re-seek must yield to it (see transportTick).
//
// The query is document-global (fine for today's single-composition Studio;
// revisit if a multi-composition editor needs to scope this to one root).
// It only runs while the clock is paused — transportTick short-circuits on
// isPlaying() — so it is off the playback hot path; one attribute selector
// per paused frame is negligible.
const hasActiveStudioManualEditGesture = (): boolean => {
try {
return document.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) != null;
} catch {
return false;
}
};
const transportTick = () => {
if (state.tornDown || inTransportTick) return;
inTransportTick = true;
try {
state.transportRafId = window.requestAnimationFrame(transportTick);
transportTickCount += 1;
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps)
if (transportTickCount % 60 === 0) {
const shouldHoldRebind =
clock.isPlaying() &&
state.capturedTimeline != null &&
clock.now() < PLAY_REBIND_HOLD_SECONDS;
if (!shouldHoldRebind) {
const prevTimeline = state.capturedTimeline;
if (bindRootTimelineIfAvailable()) {
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
state.capturedTimeline.pause();
}
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
postTimeline();
}
}
}
if (transportTickCount % 20 === 0) {
postTimeline();
}
if (transportTickCount % 30 === 0) {
bindMediaMetadataListeners();
}
// Sync clock duration with the resolved timeline each tick (catches async
// rebinds, live data-duration edits). Never shrink while playing — transient
// short reads cause reachedEnd() → playhead jumps to end (#1636).
if (state.capturedTimeline) {
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0 && (!clock.isPlaying() || dur >= clock.getDuration())) {
clock.setDuration(dur);
}
}
// Audio-master clock: three tiers of timing precision.
// 1. WebAudio (AudioContext.currentTime): ~21µs, sample-accurate
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
if (clock.isPlaying() && !state.mediaOutputMuted) {
if (
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudio.isActive() &&
webAudio.context
) {
const webAudioTime = webAudio.getTime();
if (webAudioTime >= 0) {
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
}
} else {
const audioEls = document.querySelectorAll("audio[data-start]");
let foundActive = false;
for (const rawEl of audioEls) {
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
const start = Number.parseFloat(rawEl.dataset.start ?? "");
const durAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
const mediaStart =
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") ||
0;
if (Number.isFinite(start) && state.currentTime >= start && state.currentTime <= end) {
if (!rawEl.paused) {
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
foundActive = true;
} else if (!rawEl.error && rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
// Audio is buffering — freeze visuals at last known position
// instead of falling through to monotonic (which runs ahead).
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
foundActive = true;
}
break;
}
}
if (!foundActive && clock.hasAudioSource()) {
clock.detachAudioSource();
}
}
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
const t = clock.now();
state.currentTime = t;
// During a paused Studio manual-edit drag, the draft writer owns the
// gestured element's transform (e.g. gsap.set for x/y). Re-seeking the
// timeline every frame re-applies the animated value and clobbers the
// draft, freezing the element while only the selection box tracks the
// cursor. The playhead does not advance during a paused gesture, so
// skipping the re-seek is a no-op for every other element; it resumes
// the frame the gesture marker clears (drop/cancel). Playback is never
// affected — the seek runs whenever the clock is playing.
if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) {
seekTimelineAndAdapters(t);
}
// Looping is handled at the player layer (<hyperframes-player>),
// not the runtime. The clock pauses at duration; GSAP's repeat:-1
// is bypassed because we drive tl.totalTime(t) directly. The
// parent observes isPlaying=false at end and re-issues seek(0)+play()
// if its loop attribute is set.
if (clock.isPlaying() && clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
return;
}
if (clock.isPlaying()) {
syncMediaForCurrentState();
}
postState(false);
} finally {
inTransportTick = false;
}
};
const hardSyncAllMedia = (timeSeconds: number) => {
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
if (!el.isConnected) continue;
const start = Number.parseFloat(el.dataset.start ?? "");
if (!Number.isFinite(start)) continue;
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
if (timeSeconds < start || timeSeconds >= end) continue;
const mediaStart =
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
const relTime = timeSeconds - start + mediaStart;
if (relTime >= 0) {
try {
el.currentTime = relTime;
} catch {
// ignore seek restrictions
}
}
}
};
// Player methods route through the TransportClock.
// Schedule WebAudio playback for every in-window audio clip, bounding each
// buffer to its clip window (own data-duration AND the remaining host
// composition window) so trimmed / sub-composition-nested clips stop at the
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
// handler (a rate change can't rescale a bounded source in place).
const scheduleWebAudioForActiveClips = () => {
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
const gen = webAudio.startGeneration();
const audioEls = document.querySelectorAll("audio[data-start]");
for (const rawEl of audioEls) {
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
if (!Number.isFinite(compStart)) continue;
const mediaStart =
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
const durationAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
let clipDuration =
Number.isFinite(durationAttr) && durationAttr > 0 ? durationAttr : Number.POSITIVE_INFINITY;
const compositionRoot = rawEl.closest("[data-composition-id]");
if (compositionRoot) {
const inheritedStart = resolveStartForElement(compositionRoot, 0);
const inheritedDuration = resolveDurationForElement(compositionRoot, {
includeAuthoredTimingAttrs: true,
});
if (inheritedDuration != null && inheritedDuration > 0) {
clipDuration = Math.min(
clipDuration,
Math.max(0, inheritedStart + inheritedDuration - compStart),
);
}
}
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
if (!buffer || !clock.isPlaying()) return;
void webAudio.schedulePlayback(
rawEl,
buffer,
compStart,
mediaStart,
clock.now(),
vol * state.bridgeVolume,
gen,
state.playbackRate,
clipDuration,
);
});
}
};
// Apply a new playback rate to the WebAudio transport. Unbounded sources are
// rescaled in place; but a bounded source's window was baked into start()'s
// duration at its prior rate and can't be rescaled, so when one is active we
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
const applyWebAudioRate = () => {
const changed = webAudio.setRate(state.playbackRate);
if (
changed &&
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudioReady &&
clock.isPlaying() &&
webAudio.hasBoundedActiveSources()
) {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
};
player.play = () => {
const tl = state.capturedTimeline;
if (clock.isPlaying()) return;
const dur = getSafeTimelineDurationSeconds(tl, 0);
if (dur > 0) {
clock.setDuration(dur);
if (clock.reachedEnd()) {
clock.seek(0);
state.currentTime = 0;
seekTimelineAndAdapters(0);
}
} else {
const rootEl = resolveRootCompositionElement();
const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0);
if (declaredDur > 0) clock.setDuration(declaredDur);
}
if (tl) tl.pause();
if (!clock.play()) return;
state.isPlaying = true;
state.mediaForceSyncNextTick = true;
hardSyncAllMedia(clock.now());
// Schedule audio through WebAudio for sample-accurate timing.
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
// or decoding fails (the syncRuntimeMedia path handles that).
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
scheduleWebAudioForActiveClips();
}
runAdapters("play");
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
};
player.pause = () => {
if (!clock.isPlaying()) return;
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
state.currentTime = clock.now();
state.mediaForceSyncNextTick = true;
hardSyncAllMedia(state.currentTime);
const tl = state.capturedTimeline;
if (tl) tl.pause();
runAdapters("pause");
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
};
player.seek = (timeSeconds: number) => {
const quantized = quantizeTimeToFrame(
Math.max(0, Number(timeSeconds) || 0),
state.canonicalFps,
);
webAudio.stopAll();
clock.detachAudioSource();
const wasPlaying = clock.isPlaying();
if (wasPlaying) clock.pause();
clock.seek(quantized);
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
const tl = state.capturedTimeline;
if (tl) tl.pause();
seekTimelineAndAdapters(state.currentTime);
runAdapters("pause");
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
};
player.renderSeek = (timeSeconds: number) => {
const quantized = quantizeTimeToFrame(
Math.max(0, Number(timeSeconds) || 0),
state.canonicalFps,
);
if (clock.isPlaying()) clock.pause();
clock.seek(quantized);
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
};
player.getTime = () => clock.now();
player.getDuration = () => {
const dur = clock.getDuration();
return Number.isFinite(dur) ? dur : 0;
};
player.isPlaying = () => clock.isPlaying();
player.setPlaybackRate = (rate: number) => {
applyPlaybackRate(rate);
clock.setRate(state.playbackRate);
applyWebAudioRate();
};
// Sync clock duration from any captured timeline
if (state.capturedTimeline) {
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
state.capturedTimeline.pause();
}
// Re-delegate __player methods through the live `player` object so
// transport clock overrides are visible to iframe consumers reading
// window.__player. Uses property delegation so future methods added
// to createPlayerApiCompat are forwarded automatically.
const playerApi = window.__player;
if (playerApi) {
const delegated = [
"play",
"pause",
"seek",
"renderSeek",
"getTime",
"getDuration",
"isPlaying",
] as const;
for (const key of delegated) {
Object.defineProperty(playerApi, key, {
get: () => player[key],
set: (v: unknown) => {
(player as Record<string, unknown>)[key] = v;
},
configurable: true,
});
}
}
// Start the rAF tick loop
state.transportRafId = window.requestAnimationFrame(transportTick);
postTimeline();
postState(true);
const teardown = () => {
if (state.tornDown) return;
state.tornDown = true;
if (state.transportRafId != null) {
window.cancelAnimationFrame(state.transportRafId);
state.transportRafId = null;
}
state.transportClock = null;
webAudio.destroy();
if (metadataRebindDebounceTimerId != null) {
window.clearTimeout(metadataRebindDebounceTimerId);
metadataRebindDebounceTimerId = null;
}
if (rootStageDiagnosticRafId != null) {
window.cancelAnimationFrame(rootStageDiagnosticRafId);
rootStageDiagnosticRafId = null;
}
unbindMediaMetadataListeners();
if (state.controlBridgeHandler) {
window.removeEventListener("message", state.controlBridgeHandler);
state.controlBridgeHandler = null;
}
if (runtimeErrorListener) {
window.removeEventListener("error", runtimeErrorListener);
runtimeErrorListener = null;
}
if (runtimeUnhandledRejectionListener) {
window.removeEventListener("unhandledrejection", runtimeUnhandledRejectionListener);
runtimeUnhandledRejectionListener = null;
}
if (state.beforeUnloadHandler) {
window.removeEventListener("beforeunload", state.beforeUnloadHandler);
state.beforeUnloadHandler = null;
}
picker.disablePickMode();
for (const adapter of state.deterministicAdapters) {
if (!adapter || typeof adapter.revert !== "function") continue;
try {
adapter.revert();
} catch (err) {
// keep runtime resilient against adapter cleanup failures
swallow("runtime.init.site12", err);
}
}
state.deterministicAdapters = [];
for (const cleanup of runtimeCleanupCallbacks.splice(0)) {
try {
cleanup();
} catch (err) {
// ignore cleanup failures
swallow("runtime.init.site13", err);
}
}
for (const styleEl of state.injectedCompStyles) {
try {
styleEl.remove();
} catch (err) {
// ignore cleanup failures
swallow("runtime.init.site14", err);
}
}
state.injectedCompStyles = [];
for (const scriptEl of state.injectedCompScripts) {
try {
scriptEl.remove();
} catch (err) {
// ignore cleanup failures
swallow("runtime.init.site15", err);
}
}
state.injectedCompScripts = [];
state.capturedTimeline = null;
if (window.__hfRuntimeTeardown === teardown) {
window.__hfRuntimeTeardown = null;
}
};
window.__hfRuntimeTeardown = teardown;
state.beforeUnloadHandler = teardown;
window.addEventListener("beforeunload", state.beforeUnloadHandler);
}