mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
2845 lines
112 KiB
TypeScript
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);
|
|
}
|